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 " 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 }