mirror of
https://github.com/luxfi/kms.git
synced 2026-07-27 03:38:31 +00:00
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:
+42
-10
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user