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:
@@ -0,0 +1,157 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/zitadel/oidc/v3/pkg/client"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/v3/pkg/op"
|
||||
"github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth"
|
||||
"github.com/zitadel/zitadel-go/v3/pkg/zitadel"
|
||||
)
|
||||
|
||||
// ErrAudienceNotAllowed reports a token whose aud claim names none of the
|
||||
// configured resource-server client IDs.
|
||||
var ErrAudienceNotAllowed = errors.New("audience not allowed")
|
||||
|
||||
// ZitadelConfig configures the ZITADEL-backed verifier.
|
||||
type ZitadelConfig struct {
|
||||
// Domain is the ZITADEL instance host, for example "zitadel.riotpiao.com".
|
||||
Domain string
|
||||
// InsecurePort, when non-empty, speaks plain HTTP to that port instead of
|
||||
// HTTPS on 443. Use only for a local port-forward.
|
||||
InsecurePort string
|
||||
// ClientIDs is the audience allow-list: a token is accepted when its aud
|
||||
// claim contains at least one of these client IDs. Empty accepts any
|
||||
// non-empty audience, which trusts every client of the same instance.
|
||||
ClientIDs []string
|
||||
// HTTPClient overrides the client used for discovery and JWKS fetches.
|
||||
HTTPClient *gohttp.Client
|
||||
}
|
||||
|
||||
// ZitadelVerifier validates ZITADEL access tokens with the official
|
||||
// github.com/zitadel/zitadel-go SDK.
|
||||
//
|
||||
// It reuses the SDK's building blocks rather than reimplementing JWT parsing:
|
||||
// - zitadel.New + Origin to address the instance
|
||||
// - client.Discover for OIDC discovery (issuer and JWKS URI)
|
||||
// - rp.NewRemoteKeySet for cached, auto-refreshing signing keys
|
||||
// - op.NewAccessTokenVerifier / op.VerifyAccessToken for signature, expiry
|
||||
// and issuer checks
|
||||
// - oauth.IntrospectionContext for the verified claim set and its
|
||||
// IsGrantedRole helper, which already understands the
|
||||
// urn:zitadel:iam:org:project:roles claim
|
||||
//
|
||||
// The only behaviour added on top of the SDK is a multi-value audience
|
||||
// allow-list, because this gateway is a shared resource server: callers hold
|
||||
// their own client IDs (paperless, poimen, api-gw, ...) and the SDK's
|
||||
// oauth.WithJWT helper accepts exactly one.
|
||||
type ZitadelVerifier struct {
|
||||
instance *zitadel.Zitadel
|
||||
verifier *op.AccessTokenVerifier
|
||||
clientIDs []string
|
||||
}
|
||||
|
||||
// NewZitadelVerifier performs OIDC discovery and returns a ready verifier.
|
||||
// Discovery happens once at startup so a misconfigured domain fails fast
|
||||
// instead of on the first request.
|
||||
func NewZitadelVerifier(ctx context.Context, cfg ZitadelConfig) (*ZitadelVerifier, error) {
|
||||
if cfg.Domain == "" {
|
||||
return nil, errors.New("zitadel: domain is required")
|
||||
}
|
||||
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = gohttp.DefaultClient
|
||||
}
|
||||
|
||||
var opts []zitadel.Option
|
||||
if cfg.InsecurePort != "" {
|
||||
opts = append(opts, zitadel.WithInsecure(cfg.InsecurePort))
|
||||
}
|
||||
instance := zitadel.New(cfg.Domain, opts...)
|
||||
|
||||
discovery, err := client.Discover(ctx, instance.Origin(), httpClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zitadel: OIDC discovery at %s: %w", instance.Origin(), err)
|
||||
}
|
||||
|
||||
keySet := rp.NewRemoteKeySet(httpClient, discovery.JwksURI)
|
||||
verifier := op.NewAccessTokenVerifier(discovery.Issuer, keySet)
|
||||
|
||||
return &ZitadelVerifier{
|
||||
instance: instance,
|
||||
verifier: verifier,
|
||||
clientIDs: append([]string(nil), cfg.ClientIDs...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Origin returns the instance origin used for discovery, for logging.
|
||||
func (z *ZitadelVerifier) Origin() string {
|
||||
if z == nil || z.instance == nil {
|
||||
return ""
|
||||
}
|
||||
return z.instance.Origin()
|
||||
}
|
||||
|
||||
// CheckAuthorization validates an "Authorization: Bearer <token>" header and
|
||||
// returns the SDK's verified introspection context.
|
||||
func (z *ZitadelVerifier) CheckAuthorization(ctx context.Context, authHeader string) (*oauth.IntrospectionContext, error) {
|
||||
accessToken, ok := strings.CutPrefix(authHeader, oidc.BearerToken)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: expected %q prefix", oauth.ErrInvalidAuthorizationHeader, oidc.BearerToken)
|
||||
}
|
||||
accessToken = strings.TrimSpace(accessToken)
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("%w: empty bearer token", oauth.ErrInvalidAuthorizationHeader)
|
||||
}
|
||||
|
||||
claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, z.verifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", oauth.ErrInvalidToken, err)
|
||||
}
|
||||
|
||||
if len(claims.Audience) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty aud", oauth.ErrInvalidToken)
|
||||
}
|
||||
if !z.audienceAllowed(claims.Audience) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrAudienceNotAllowed, claims.Audience)
|
||||
}
|
||||
|
||||
authCtx := &oauth.IntrospectionContext{
|
||||
IntrospectionResponse: oidc.IntrospectionResponse{
|
||||
Active: true,
|
||||
Issuer: claims.Issuer,
|
||||
Subject: claims.Subject,
|
||||
Audience: claims.Audience,
|
||||
Expiration: claims.Expiration,
|
||||
IssuedAt: claims.IssuedAt,
|
||||
NotBefore: claims.NotBefore,
|
||||
ClientID: claims.ClientID,
|
||||
JWTID: claims.JWTID,
|
||||
Claims: claims.Claims,
|
||||
},
|
||||
}
|
||||
authCtx.SetToken(accessToken)
|
||||
return authCtx, nil
|
||||
}
|
||||
|
||||
// audienceAllowed reports whether any aud entry is in the allow-list.
|
||||
// An empty allow-list accepts every non-empty audience.
|
||||
func (z *ZitadelVerifier) audienceAllowed(audience []string) bool {
|
||||
if len(z.clientIDs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, aud := range audience {
|
||||
if slices.Contains(z.clientIDs, aud) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user