fix(auth): pin hanzo IAM issuer to hanzo.id + reject missing exp (red FIX1/INFO)

FIX1 (red, HIGH — the deploy landmine): brand.go defaulted the `hanzo` brand
IAMIssuer to https://iam.hanzo.ai, but the live .well-known/openid-configuration
on BOTH hanzo.id and iam.hanzo.ai reports issuer=https://hanzo.id +
jwks_uri=https://hanzo.id/v1/iam/.well-known/jwks (iam.hanzo.ai is a routing
alias, not the token issuer). With the baked default, SanitizeIdentity's issuer
check would fail on every real token -> every principal anonymized -> ALL global
admin gets 403 (fail-secure, no forgery opened, but admin broken platform-wide).
The cloud CLI already defaults to hanzo.id; lux/zoo/pars already point at their
own .id issuers. Pin hanzo -> https://hanzo.id so the correct config is
default-by-default. (JWKS derivation then yields the correct hanzo.id JWKS.)

INFO (red): go-jose ValidateWithLeeway only enforces exp when present
(`if c.Expiry != nil`), so a token with NO exp would never expire. Reject a
missing exp explicitly, exactly like a missing iss. +1 test (22 green).

No subsystem reads cfg.IAMIssuer except SanitizeIdentity + a log line, so the
brand default change is contained.
This commit is contained in:
2026-06-30 15:39:08 -07:00
parent eb77547072
commit 21ac43f13c
3 changed files with 26 additions and 2 deletions
+6
View File
@@ -104,6 +104,12 @@ func (v *identityValidator) validate(raw string) (*idClaims, error) {
if claims.Issuer == "" {
return nil, fmt.Errorf("missing issuer")
}
// Reject a missing expiry: ValidateWithLeeway only enforces exp when present
// (it checks `if c.Expiry != nil`), so a token with NO exp would never expire.
// An IAM access token always carries exp; require it.
if claims.Expiry == nil {
return nil, fmt.Errorf("missing expiry")
}
expected := jwt.Expected{Issuer: v.issuer}
if len(v.audiences) > 0 {
expected.AnyAudience = jwt.Audience(v.audiences)
+11 -2
View File
@@ -29,10 +29,19 @@ type BrandInfo struct {
}
// brands is the brand→IAM registry. Keys are the canonical brand IDs accepted
// by CLOUD_BRAND. Per HIP-0111 §Brands: hanzo→iam.hanzo.ai, lux→lux.id,
// by CLOUD_BRAND. Per HIP-0111 §Brands: hanzo→hanzo.id, lux→lux.id,
// zoo→zoo.id, pars→pars.id, bootnode→id.bootno.de.
//
// IAMIssuer MUST equal the `iss` IAM actually stamps AND host the signing JWKS.
// For hanzo the live .well-known/openid-configuration on BOTH hanzo.id and
// iam.hanzo.ai reports issuer=https://hanzo.id + jwks_uri=
// https://hanzo.id/v1/iam/.well-known/jwks (iam.hanzo.ai is a routing alias, not
// the issuer), and the cloud CLI already defaults to hanzo.id. Pinning
// iam.hanzo.ai here would fail the issuer check on every real token, anonymizing
// every principal — global admin would 403 platform-wide (fail-secure, but
// broken). lux/zoo/pars already correctly point at their own .id issuers.
var brands = map[string]BrandInfo{
"hanzo": {ID: "hanzo", IAMIssuer: "https://iam.hanzo.ai", Domain: "hanzo.ai"},
"hanzo": {ID: "hanzo", IAMIssuer: "https://hanzo.id", Domain: "hanzo.ai"},
"lux": {ID: "lux", IAMIssuer: "https://lux.id", Domain: "lux.network"},
"zoo": {ID: "zoo", IAMIssuer: "https://zoo.id", Domain: "zoo.ngo"},
"pars": {ID: "pars", IAMIssuer: "https://pars.id", Domain: "pars.network"},
+9
View File
@@ -306,6 +306,15 @@ func TestIdentityValidator(t *testing.T) {
t.Fatal("expired token must be rejected")
}
})
t.Run("missing expiry rejected", func(t *testing.T) {
// go-jose only enforces exp when present; a token with NO exp would never
// expire. We reject it explicitly.
c := tokenClaims("hanzo-console", "admin", "", true, future)
c.Expiry = nil
if _, err := v.validate(signWith(t, key, c)); err == nil {
t.Fatal("token without exp must be rejected")
}
})
t.Run("bad signature rejected", func(t *testing.T) {
if _, err := v.validate(signWith(t, other, tokenClaims("hanzo-console", "admin", "", true, future))); err == nil {
t.Fatal("token signed by an unknown key must be rejected")