diff --git a/cmd/kms/auth.go b/cmd/kms/auth.go index 0e6f49114..cd3126551 100644 --- a/cmd/kms/auth.go +++ b/cmd/kms/auth.go @@ -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") } diff --git a/cmd/kms/auth_test.go b/cmd/kms/auth_test.go index 429bcdc1f..ac9af84d6 100644 --- a/cmd/kms/auth_test.go +++ b/cmd/kms/auth_test.go @@ -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