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.
158 lines
5.2 KiB
Go
158 lines
5.2 KiB
Go
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
|
|
}
|