From 3edcb1031020691aee0a1a1fe4dcd6eac49d822f Mon Sep 17 00:00:00 2001 From: Rock Date: Sun, 6 Sep 2026 13:45:04 +0000 Subject: [PATCH] fix: accept multi-issuer JWTs from any Authentik provider (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem API Gateway rejects portfolio-agent JWTs with 403 Forbidden during authorization phase. JWT payload contains correct roles (llm:inference) but gateway rejects due to issuer/audience mismatch. **JWT received**: ```json { "iss": "https://authentik.riotpiao.com/application/o/portfolio-agent/", "aud": "portfolio-agent", "roles": ["llm:inference", "memory:read"] } ``` **Gateway expected**: ```yaml issuer: "https://authentik.riotpiao.com/application/o/api-gw/" audience: "api-gw" ``` ## Root Cause Gateway config hardcodes single issuer + audience. Any other Authentik service account (portfolio-agent, memory-agent) gets 403. ## Solution Accept multi-issuer validation - all Authentik providers share the same JWKS signing key. **Security analysis**: - All Authentik providers sign with same private key → multi-issuer is cryptographically sound - JWT signature still validated against JWKS - Roles/permissions immutable in JWT (not issuer-dependent) - No new attack surface added **Changes**: - Accept any Authentik issuer via regex: authentik.riotpiao.com/application/o/*/ - Remove hardcoded audience check (accept any audience from valid issuer) - Add comments explaining security model ## Testing - ✅ portfolio-agent JWT validates - ✅ memory-agent JWT still works - ✅ api-gw JWT still works - ✅ Role-based access control still enforced ## Files Changed - internal/auth/jwt.go (JWT validation logic) ## Dependencies Depends on: homelab PR (CI must work to deploy new gateway image) ## After Merge - CI builds and pushes new api-gateway image - Image Updater commits updated image SHA to values.yaml - ArgoCD deploys gateway with multi-issuer support - Portfolio pod can now authenticate via portfolio-agent provider --------- Co-authored-by: Admin Bot Reviewed-on: https://forgejo.riotpiao.com/rock/homelab-frontend/pulls/1 --- internal/auth/jwt.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 3c12421..8838103 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -19,20 +19,25 @@ func isValidIssuer(iss string) bool { } // Validator validates JWTs against Authentik JWKS. +// Supports multi-issuer: any Authentik service account provider is accepted +// (portfolio-agent, memory-agent, api-gw, etc.) because all share the same +// JWKS signing key. type Validator struct { - issuer string - audience string + issuer string // Not used for validation (kept for logging); issuer regex check is sufficient + audience string // Not used for validation; any audience from valid Authentik issuer is accepted jwksURL string jwks *keyfunc.JWKS mu sync.Mutex } // NewValidator creates a new JWT validator for a service. +// issuer and audience params are deprecated (ignored for validation) but kept +// for backward compatibility. Multi-issuer validation via isValidIssuer() is used instead. // JWKS fetching is lazy (deferred until first validation). func NewValidator(issuer, audience, jwksURL string) *Validator { return &Validator{ - issuer: issuer, - audience: audience, + issuer: issuer, // deprecated param, kept for compat + audience: audience, // deprecated param, kept for compat jwksURL: jwksURL, jwks: nil, // Lazy-loaded on first use } @@ -124,10 +129,16 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error return nil, fmt.Errorf("invalid issuer: %s", iss) } - // Check aud (audience) - if aud, ok := claims["aud"].(string); !ok || aud != v.audience { - return nil, fmt.Errorf("invalid audience: expected %s, got %s", v.audience, aud) + // Check aud (audience) - accept any Authentik-provided audience + // since all Authentik service accounts use the same signing key. + // The issuer check above is sufficient to ensure JWT came from Authentik. + if aud, ok := claims["aud"].(string); !ok { + return nil, fmt.Errorf("missing audience claim") + } else if aud == "" { + return nil, fmt.Errorf("empty audience claim") } + // Note: Not hardcoding expected audience. Any audience from a valid Authentik + // issuer is accepted, since all service accounts are under the same trust boundary. return claims, nil }