feat(auth): validate access tokens with the ZITADEL Go SDK
Replace the handwritten Authentik JWT checks with github.com/zitadel/zitadel-go/v3. What the SDK now owns: - OIDC discovery (client.Discover) to find issuer and JWKS URI - remote, auto-refreshing JWKS (rp.NewRemoteKeySet) - signature, expiry and issuer checks (op.NewAccessTokenVerifier, op.VerifyAccessToken) - the verified claim set and role lookup (oauth.IntrospectionContext, IsGrantedRole) What stays local: - a multi-audience allow-list, because this gateway is a shared resource server and the SDK's oauth.WithJWT accepts exactly one client ID - an adapter from IntrospectionContext to jwt.MapClaims so the rest of the gateway keeps one claim representation Other changes: - issuer is configuration-driven; the hardcoded authentik.riotpiao.com check is gone - aud may be a string or an array (ZITADEL sends an array) - CheckPermissions also reads urn:zitadel:iam:org:project:roles, the claim ZITADEL uses for project roles - requiredCapability accepts a comma-separated list, so llm:inference and llm-inference both satisfy the LLM gate - a five-segment token is reported as an encrypted JWE with the fix in the message, instead of failing as a bad signature Config: auth.zitadelDomain turns the SDK path on; jwksUrl stays supported for the legacy path and for tests.
This commit is contained in:
+190
-32
@@ -3,37 +3,141 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/MicahParks/keyfunc/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth"
|
||||
)
|
||||
|
||||
// isValidIssuer checks if issuer is from Authentik (any provider/app).
|
||||
// Accepts: https://authentik.riotpiao.com/application/o/{provider}/
|
||||
func isValidIssuer(iss string) bool {
|
||||
return strings.Contains(iss, "authentik.riotpiao.com/application/o/") &&
|
||||
strings.HasSuffix(iss, "/")
|
||||
// issuerMatches reports whether iss is the configured issuer or a sub-path of
|
||||
// it. Zitadel emits exactly "https://zitadel.riotpiao.com"; providers that
|
||||
// mount an application path append a suffix (Authentik used
|
||||
// "/application/o/<app>/"). A trailing slash on either side is ignored.
|
||||
//
|
||||
// An empty configured issuer disables the domain check; the JWKS signature
|
||||
// check still applies. Production configuration always sets an issuer.
|
||||
func issuerMatches(iss, configured string) bool {
|
||||
if configured == "" {
|
||||
return iss != ""
|
||||
}
|
||||
iss = strings.TrimSuffix(iss, "/")
|
||||
configured = strings.TrimSuffix(configured, "/")
|
||||
return iss == configured || strings.HasPrefix(iss, configured+"/")
|
||||
}
|
||||
|
||||
// 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.
|
||||
// audienceValues normalises the aud claim. Zitadel access tokens carry an
|
||||
// array, older providers carry a single string.
|
||||
func audienceValues(claims jwt.MapClaims) ([]string, bool) {
|
||||
switch v := claims["aud"].(type) {
|
||||
case string:
|
||||
return []string{v}, v != ""
|
||||
case []string:
|
||||
return v, len(v) > 0
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0
|
||||
case jwt.ClaimStrings:
|
||||
return []string(v), len(v) > 0
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Validator validates JWTs against the JWKS of an OIDC provider (Zitadel).
|
||||
// The issuer is configuration-driven: tokens whose iss claim equals the
|
||||
// configured issuer, or lives under it as a sub-path, are accepted.
|
||||
//
|
||||
// When a ZitadelVerifier is attached (see NewZitadelValidator), validation
|
||||
// delegates to the official ZITADEL Go SDK and this type only adapts the
|
||||
// result to jwt.MapClaims for the rest of the gateway.
|
||||
type Validator struct {
|
||||
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
|
||||
issuer string // Expected issuer (iss claim); empty disables the domain check
|
||||
audience string // Expected audience; currently logged only (any non-empty aud is accepted)
|
||||
jwksURL string
|
||||
jwks *keyfunc.JWKS
|
||||
mu sync.Mutex
|
||||
zitadel *ZitadelVerifier // non-nil when backed by the ZITADEL SDK
|
||||
}
|
||||
|
||||
// NewZitadelValidator builds a Validator backed by the ZITADEL Go SDK.
|
||||
// issuer is the expected iss claim, used for logging and for the legacy
|
||||
// claim checks if the SDK verifier is ever bypassed. clientIDs is the
|
||||
// audience allow-list. Discovery runs here, so a wrong domain fails at
|
||||
// startup.
|
||||
func NewZitadelValidator(ctx context.Context, issuer string, clientIDs []string, cfg ZitadelConfig) (*Validator, error) {
|
||||
return NewZitadelValidatorWithClient(ctx, issuer, clientIDs, cfg, nil)
|
||||
}
|
||||
|
||||
// NewZitadelValidatorWithClient is NewZitadelValidator with an injected HTTP
|
||||
// client, for tests that serve discovery and JWKS from httptest.
|
||||
func NewZitadelValidatorWithClient(ctx context.Context, issuer string, clientIDs []string, cfg ZitadelConfig, httpClient *gohttp.Client) (*Validator, error) {
|
||||
cfg.ClientIDs = clientIDs
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = httpClient
|
||||
}
|
||||
sdk, err := NewZitadelVerifier(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Validator{
|
||||
issuer: issuer,
|
||||
jwksURL: "", // unused on the SDK path
|
||||
zitadel: sdk,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// claimsFromIntrospection converts the SDK's verified claim set into
|
||||
// jwt.MapClaims so the rest of the gateway keeps one claim representation.
|
||||
// The SDK has already checked the signature, exp, nbf and iss; this only
|
||||
// copies values across.
|
||||
func claimsFromIntrospection(authCtx *oauth.IntrospectionContext) jwt.MapClaims {
|
||||
claims := jwt.MapClaims{}
|
||||
if authCtx == nil {
|
||||
return claims
|
||||
}
|
||||
for k, v := range authCtx.Claims {
|
||||
claims[k] = v
|
||||
}
|
||||
if authCtx.Issuer != "" {
|
||||
claims["iss"] = authCtx.Issuer
|
||||
}
|
||||
if authCtx.Subject != "" {
|
||||
claims["sub"] = authCtx.Subject
|
||||
}
|
||||
if len(authCtx.Audience) > 0 {
|
||||
aud := make([]interface{}, 0, len(authCtx.Audience))
|
||||
for _, a := range authCtx.Audience {
|
||||
aud = append(aud, a)
|
||||
}
|
||||
claims["aud"] = aud
|
||||
}
|
||||
if authCtx.Expiration != 0 {
|
||||
claims["exp"] = float64(authCtx.Expiration)
|
||||
}
|
||||
if authCtx.NotBefore != 0 {
|
||||
claims["nbf"] = float64(authCtx.NotBefore)
|
||||
}
|
||||
if authCtx.IssuedAt != 0 {
|
||||
claims["iat"] = float64(authCtx.IssuedAt)
|
||||
}
|
||||
if authCtx.ClientID != "" {
|
||||
claims["client_id"] = authCtx.ClientID
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
// 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).
|
||||
// issuer is the expected iss claim (exact value or prefix). audience is kept
|
||||
// for compatibility; only non-emptiness is enforced. JWKS fetching is lazy
|
||||
// (deferred until first validation).
|
||||
func NewValidator(issuer, audience, jwksURL string) *Validator {
|
||||
return &Validator{
|
||||
issuer: issuer, // deprecated param, kept for compat
|
||||
@@ -87,8 +191,31 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error
|
||||
}
|
||||
|
||||
// Ensure JWKS is loaded (lazy)
|
||||
if err := v.ensureJWKS(); err != nil {
|
||||
return nil, err
|
||||
if v.jwks != nil || v.zitadel == nil {
|
||||
if err := v.ensureJWKS(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// SDK path: ZITADEL Go SDK performs signature, expiry and issuer checks.
|
||||
if v.zitadel != nil {
|
||||
// ZITADEL issues two access-token shapes. A plain JWS has three
|
||||
// dot-separated segments and can be verified locally. A JWE has five,
|
||||
// is encrypted with the instance key, and only ZITADEL can read it.
|
||||
// Report that case explicitly so the operator changes the client to
|
||||
// OIDC_TOKEN_TYPE_BEARER instead of debugging a signature failure.
|
||||
if strings.Count(tokenString, ".") == 4 {
|
||||
return nil, fmt.Errorf("token is an encrypted JWE (5 segments); " +
|
||||
"set accessTokenType=OIDC_TOKEN_TYPE_BEARER on the ZITADEL client so the gateway can verify it locally")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
authCtx, err := v.zitadel.CheckAuthorization(ctx, authHeader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token validation failed: %v", err)
|
||||
}
|
||||
return claimsFromIntrospection(authCtx), nil
|
||||
}
|
||||
|
||||
// Parse and validate
|
||||
@@ -120,34 +247,35 @@ func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error
|
||||
}
|
||||
}
|
||||
|
||||
// Check iss (issuer) - accept any Authentik provider issuer
|
||||
// (portfolio-agent, memory-agent, api-gw, etc.)
|
||||
// All use same signing key so JWKS validation is sufficient
|
||||
// Check iss against the configured issuer (exact match or sub-path).
|
||||
if iss, ok := claims["iss"].(string); !ok {
|
||||
return nil, fmt.Errorf("missing issuer claim")
|
||||
} else if !isValidIssuer(iss) {
|
||||
return nil, fmt.Errorf("invalid issuer: %s", iss)
|
||||
} else if !issuerMatches(iss, v.issuer) {
|
||||
return nil, fmt.Errorf("invalid issuer: %s (want %s)", iss, v.issuer)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Check aud - Zitadel sends an array; accept a string or array.
|
||||
if auds, ok := audienceValues(claims); !ok {
|
||||
return nil, fmt.Errorf("missing audience claim")
|
||||
} else if aud == "" {
|
||||
} else if len(auds) == 0 {
|
||||
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.
|
||||
// The signature check plus the issuer check establish the trust boundary;
|
||||
// the audience value itself is not hardcoded.
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// CheckPermissions checks if claims contain required permission(s).
|
||||
// Checks both "permissions" claim (for users) and "roles" claim (for service accounts).
|
||||
// Understands three token shapes:
|
||||
// - "permissions": []string (generic / legacy tokens)
|
||||
// - "roles": []string or map[string]… (legacy service-account tokens)
|
||||
// - "urn:zitadel:iam:org:project:roles": map[string]… (Zitadel access tokens,
|
||||
// where the map keys are role keys)
|
||||
//
|
||||
// Returns true if any required permission is found or wildcard "*" exists.
|
||||
func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) bool {
|
||||
// Try permissions claim first (for user tokens)
|
||||
// 1. permissions claim (array of strings)
|
||||
if permsIface, ok := claims["permissions"]; ok {
|
||||
if perms, ok := permsIface.([]interface{}); ok {
|
||||
if v.checkPermList(perms, required...) {
|
||||
@@ -156,15 +284,45 @@ func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) b
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to roles claim (for service account tokens)
|
||||
// 2. roles claim: array (legacy) or map keyed by role name
|
||||
if rolesIface, ok := claims["roles"]; ok {
|
||||
if roles, ok := rolesIface.([]interface{}); ok {
|
||||
switch roles := rolesIface.(type) {
|
||||
case []interface{}:
|
||||
if v.checkPermList(roles, required...) {
|
||||
return true
|
||||
}
|
||||
case map[string]interface{}:
|
||||
if v.checkPermMap(roles, required...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Zitadel access-token role assertion: map keyed by role key
|
||||
if rolesIface, ok := claims["urn:zitadel:iam:org:project:roles"]; ok {
|
||||
if roles, ok := rolesIface.(map[string]interface{}); ok {
|
||||
if v.checkPermMap(roles, required...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// checkPermMap reports whether any key in a role map matches a required
|
||||
// permission, or the map carries a wildcard key.
|
||||
func (v *Validator) checkPermMap(roles map[string]interface{}, required ...string) bool {
|
||||
for roleKey := range roles {
|
||||
if roleKey == "*" {
|
||||
return true
|
||||
}
|
||||
for _, req := range required {
|
||||
if roleKey == req {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user