fix(kms): accept multiple white-label issuers (lux.id + hanzo.id)

KMS_EXPECTED_ISSUER now takes a comma-separated allowlist so one KMS can
serve several white-label brands that share IAM signing keys. The Lux-brand
KMS (kms.lux.network) must accept lux.id tokens — lux.cloud users carry
iss=https://lux.id — while still honoring hanzo.id. JWKS is unchanged
(fetched in-cluster; both brands sign with the same keys), so this is a pure
issuer-string allowlist: parse+dedupe, then validate membership after the
signature/expiry check. Backward compatible: a single-value
KMS_EXPECTED_ISSUER behaves exactly as before.

Deploy: KMS_EXPECTED_ISSUER=https://hanzo.id,https://lux.id on the Lux KMS.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-14 10:15:00 -07:00
co-authored by Hanzo Dev
parent 8d066a99ae
commit 415097f535
2 changed files with 97 additions and 10 deletions
+42 -10
View File
@@ -108,7 +108,7 @@ func orgAuthorizes(tokenOrg, requested string) bool {
// a gateway that rewrites Host.
type orgJWTAuth struct {
jwksURL string
issuer string
issuers []string // accepted `iss` values; a white-label KMS trusts every brand IAM that shares its signing keys
cache *jwksCache
}
@@ -118,14 +118,14 @@ type orgJWTAuth struct {
// iamEndpoint is used (matches the simple single-URL deployment).
func newOrgJWTAuth(iamEndpoint, expectedIssuer string) *orgJWTAuth {
iam := strings.TrimRight(iamEndpoint, "/")
iss := strings.TrimRight(expectedIssuer, "/")
if iss == "" {
iss = iam
issuers := parseIssuers(expectedIssuer)
if len(issuers) == 0 {
issuers = []string{iam}
}
jwksURL := iam + "/.well-known/jwks"
return &orgJWTAuth{
jwksURL: jwksURL,
issuer: iss,
issuers: issuers,
cache: &jwksCache{
url: jwksURL,
ttl: 5 * time.Minute,
@@ -134,6 +134,37 @@ func newOrgJWTAuth(iamEndpoint, expectedIssuer string) *orgJWTAuth {
}
}
// parseIssuers normalizes KMS_EXPECTED_ISSUER into the set of accepted
// `iss` values. A comma-separated list lets one KMS serve multiple
// white-label brands that share IAM signing keys — e.g. a Lux-brand KMS
// trusting both `https://hanzo.id` and `https://lux.id`. Order-preserving,
// de-duplicated, trailing slashes trimmed.
func parseIssuers(s string) []string {
out := []string{}
seen := map[string]bool{}
for _, part := range strings.Split(s, ",") {
v := strings.TrimRight(strings.TrimSpace(part), "/")
if v == "" || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
return out
}
// issuerAllowed reports whether a token's `iss` claim is one of the
// configured accepted issuers (trailing-slash insensitive).
func issuerAllowed(allowed []string, iss string) bool {
iss = strings.TrimRight(iss, "/")
for _, a := range allowed {
if iss == a {
return true
}
}
return false
}
func (a *orgJWTAuth) validate(ctx context.Context, raw string) (*orgClaims, error) {
if raw == "" {
return nil, errors.New("empty token")
@@ -193,13 +224,14 @@ func (a *orgJWTAuth) validate(ctx context.Context, raw string) (*orgClaims, erro
}
return nil, fmt.Errorf("verify: %w", lastErr)
}
exp := jwt.Expected{
Time: time.Now(),
Issuer: a.issuer,
}
if err := claims.Claims.Validate(exp); err != nil {
// Time/expiry via go-jose; issuer is checked separately so we can accept
// a set of white-label brand issuers (go-jose's Expected pins exactly one).
if err := claims.Claims.Validate(jwt.Expected{Time: time.Now()}); err != nil {
return nil, fmt.Errorf("validate: %w", err)
}
if !issuerAllowed(a.issuers, claims.Issuer) {
return nil, fmt.Errorf("validate: issuer %q not accepted", claims.Issuer)
}
if len(claims.orgs()) == 0 {
return nil, errors.New("token has no resolvable org")
}
+55
View File
@@ -469,6 +469,61 @@ func TestRequireOrgJWT_splitJwksAndIssuer(t *testing.T) {
}
}
// White-label: one KMS may accept several brand issuers via a
// comma-separated KMS_EXPECTED_ISSUER. A token minted by ANY listed
// issuer must validate; one minted by an unlisted issuer must 401. This
// is the fix that lets the Lux-brand KMS accept lux.id tokens while still
// honoring hanzo.id (both brands share IAM signing keys).
func TestRequireOrgJWT_multiIssuerWhiteLabel(t *testing.T) {
signer, jwks := newTestSigner(t)
jwksHost := httptest.NewServer(jwksHandler(jwks))
defer jwksHost.Close()
// Note the whitespace + trailing slash — parseIssuers must normalize both.
auth := newOrgJWTAuth(jwksHost.URL, "https://hanzo.id, https://lux.id/")
mkTok := func(iss string) string {
return signOrgClaims(t, signer, orgClaims{
Claims: jwt.Claims{
Issuer: iss,
Subject: "admin/sdm-kms",
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
Owner: "admin",
Name: "sdm-kms",
Type: "application",
})
}
cases := []struct {
name string
iss string
want int
}{
{"hanzo.id accepted", "https://hanzo.id", http.StatusOK},
{"lux.id accepted", "https://lux.id", http.StatusOK},
{"trailing slash tolerated", "https://lux.id/", http.StatusOK},
{"unlisted issuer rejected", "https://evil.example.com", http.StatusUnauthorized},
}
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/kms/orgs/{org}/secrets/{rest...}", auth.requireOrgJWT(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/kms/orgs/sdm/secrets/staking/staker.crt", nil)
req.Header.Set("Authorization", "Bearer "+mkTok(c.iss))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("iss=%s: got %d want %d; body=%s", c.iss, rec.Code, c.want, rec.Body.String())
}
})
}
}
func TestBearerToken_extracts(t *testing.T) {
cases := []struct {
header, want string