Files
homelab-frontend/internal/auth/zitadel.go
T
Admin Bot 8a3a1c447c 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.
2026-10-26 10:23:00 -07:00

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
}