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,21 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateBearerToken_JWEGetsClearError(t *testing.T) {
|
||||
ti := newTestIssuer(t)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
|
||||
v, err := NewZitadelValidator(t.Context(), ti.server.URL, nil, ZitadelConfig{
|
||||
Domain: host, InsecurePort: port, HTTPClient: ti.server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewZitadelValidator: %v", err)
|
||||
}
|
||||
_, err = v.ValidateBearerToken("Bearer a.b.c.d.e")
|
||||
if err == nil || !strings.Contains(err.Error(), "encrypted JWE") {
|
||||
t.Fatalf("expected a JWE-specific error, got %v", err)
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// testIssuer mimics one ZITADEL instance: OIDC discovery on
|
||||
// /.well-known/openid-configuration and the JWKS on /oauth/v2/keys.
|
||||
type testIssuer struct {
|
||||
server *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
kid string
|
||||
jwksHit int
|
||||
}
|
||||
|
||||
func newTestIssuer(t *testing.T) *testIssuer {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
|
||||
ti := &testIssuer{key: key, kid: "test-kid-1"}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": ti.server.URL,
|
||||
"jwks_uri": ti.server.URL + "/oauth/v2/keys",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/oauth/v2/keys", func(w http.ResponseWriter, r *http.Request) {
|
||||
ti.jwksHit++
|
||||
pub := key.PublicKey
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"keys": []map[string]any{{
|
||||
"kty": "RSA",
|
||||
"kid": ti.kid,
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
||||
}},
|
||||
})
|
||||
})
|
||||
|
||||
ti.server = httptest.NewServer(mux)
|
||||
t.Cleanup(ti.server.Close)
|
||||
return ti
|
||||
}
|
||||
|
||||
// sign mints an access token with the given audience and project roles.
|
||||
func (ti *testIssuer) sign(t *testing.T, aud []string, roles map[string]any) string {
|
||||
t.Helper()
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"iss": ti.server.URL,
|
||||
"sub": "396757800000000001",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
if len(aud) > 0 {
|
||||
audClaim := make([]interface{}, 0, len(aud))
|
||||
for _, a := range aud {
|
||||
audClaim = append(audClaim, a)
|
||||
}
|
||||
claims["aud"] = audClaim
|
||||
}
|
||||
if roles != nil {
|
||||
claims["urn:zitadel:iam:org:project:roles"] = roles
|
||||
}
|
||||
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tok.Header["kid"] = ti.kid
|
||||
signed, err := tok.SignedString(ti.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// verifierFor builds a ZitadelVerifier pointed at the httptest instance.
|
||||
// The SDK is given the loopback host and its port, so discovery goes to the
|
||||
// test server rather than a real domain.
|
||||
func verifierFor(t *testing.T, ti *testIssuer, clientIDs []string) *ZitadelVerifier {
|
||||
t.Helper()
|
||||
|
||||
host, port, ok := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
|
||||
if !ok {
|
||||
t.Fatalf("unexpected test server URL %q", ti.server.URL)
|
||||
}
|
||||
|
||||
v, err := NewZitadelVerifier(context.Background(), ZitadelConfig{
|
||||
Domain: host,
|
||||
InsecurePort: port,
|
||||
ClientIDs: clientIDs,
|
||||
HTTPClient: ti.server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewZitadelVerifier: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestZitadelVerifier_AcceptsTokenWithAllowedAudience(t *testing.T) {
|
||||
ti := newTestIssuer(t)
|
||||
v := verifierFor(t, ti, []string{"396757803559027071", "396757800874672511"})
|
||||
|
||||
token := ti.sign(t, []string{"396757803559027071"}, map[string]any{
|
||||
"llm:inference": map[string]any{"396757796898471937": "homelab"},
|
||||
})
|
||||
|
||||
authCtx, err := v.CheckAuthorization(context.Background(), "Bearer "+token)
|
||||
if err != nil {
|
||||
t.Fatalf("expected token to pass, got %v", err)
|
||||
}
|
||||
if !authCtx.IsAuthorized() {
|
||||
t.Error("expected IsAuthorized() true")
|
||||
}
|
||||
if want := "396757800000000001"; authCtx.UserID() != want {
|
||||
t.Errorf("UserID() = %q, want %q", authCtx.UserID(), want)
|
||||
}
|
||||
if !authCtx.IsGrantedRole("llm:inference") {
|
||||
t.Error("expected SDK IsGrantedRole to see llm:inference from the project roles claim")
|
||||
}
|
||||
if ti.jwksHit == 0 {
|
||||
t.Error("expected the SDK to fetch the JWKS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZitadelVerifier_RejectsForeignAudience(t *testing.T) {
|
||||
ti := newTestIssuer(t)
|
||||
v := verifierFor(t, ti, []string{"396757803559027071"})
|
||||
|
||||
token := ti.sign(t, []string{"999999999999999999"}, nil)
|
||||
|
||||
_, err := v.CheckAuthorization(context.Background(), "Bearer "+token)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection for an audience outside the allow-list")
|
||||
}
|
||||
if !strings.Contains(err.Error(), ErrAudienceNotAllowed.Error()) {
|
||||
t.Errorf("expected ErrAudienceNotAllowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZitadelVerifier_AcceptsAnyAudienceWhenAllowListEmpty(t *testing.T) {
|
||||
ti := newTestIssuer(t)
|
||||
v := verifierFor(t, ti, nil)
|
||||
|
||||
token := ti.sign(t, []string{"396757803877794175"}, nil)
|
||||
|
||||
if _, err := v.CheckAuthorization(context.Background(), "Bearer "+token); err != nil {
|
||||
t.Fatalf("empty allow-list should accept any audience, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZitadelVerifier_RejectsMissingBearerPrefix(t *testing.T) {
|
||||
ti := newTestIssuer(t)
|
||||
v := verifierFor(t, ti, nil)
|
||||
|
||||
token := ti.sign(t, []string{"396757803877794175"}, nil)
|
||||
|
||||
if _, err := v.CheckAuthorization(context.Background(), token); err == nil {
|
||||
t.Fatal("expected rejection when the Bearer prefix is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZitadelVerifier_RejectsExpiredToken(t *testing.T) {
|
||||
ti := newTestIssuer(t)
|
||||
v := verifierFor(t, ti, nil)
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"iss": ti.server.URL,
|
||||
"sub": "u1",
|
||||
"aud": []interface{}{"396757803877794175"},
|
||||
"exp": time.Now().Add(-2 * time.Hour).Unix(),
|
||||
"iat": time.Now().Add(-3 * time.Hour).Unix(),
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tok.Header["kid"] = ti.kid
|
||||
signed, err := tok.SignedString(ti.key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := v.CheckAuthorization(context.Background(), "Bearer "+signed); err == nil {
|
||||
t.Fatal("expected rejection for an expired token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidator_SDKPathFeedsCheckPermissions proves the adapter from the SDK
|
||||
// introspection context to jwt.MapClaims keeps the capability gate working.
|
||||
func TestValidator_SDKPathFeedsCheckPermissions(t *testing.T) {
|
||||
ti := newTestIssuer(t)
|
||||
host, port, _ := strings.Cut(strings.TrimPrefix(ti.server.URL, "http://"), ":")
|
||||
|
||||
validator, err := NewZitadelValidator(context.Background(), ti.server.URL, []string{"396757803559027071"}, ZitadelConfig{
|
||||
Domain: host,
|
||||
InsecurePort: port,
|
||||
HTTPClient: ti.server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewZitadelValidator: %v", err)
|
||||
}
|
||||
|
||||
good := ti.sign(t, []string{"396757803559027071"}, map[string]any{
|
||||
"llm:inference": map[string]any{"396757796898471937": "homelab"},
|
||||
})
|
||||
claims, err := validator.ValidateBearerToken("Bearer " + good)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateBearerToken: %v", err)
|
||||
}
|
||||
if !validator.CheckPermissions(claims, "llm:inference") {
|
||||
t.Error("expected CheckPermissions to accept llm:inference via the SDK path")
|
||||
}
|
||||
if validator.CheckPermissions(claims, "sqs:write") {
|
||||
t.Error("expected CheckPermissions to reject a role the token does not carry")
|
||||
}
|
||||
|
||||
noRole := ti.sign(t, []string{"396757803559027071"}, nil)
|
||||
claims, err = validator.ValidateBearerToken("Bearer " + noRole)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateBearerToken without roles: %v", err)
|
||||
}
|
||||
if validator.CheckPermissions(claims, "llm:inference") {
|
||||
t.Error("expected CheckPermissions to reject a token with no project roles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
iss string
|
||||
configured string
|
||||
want bool
|
||||
}{
|
||||
{"https://zitadel.riotpiao.com", "https://zitadel.riotpiao.com", true},
|
||||
{"https://zitadel.riotpiao.com/", "https://zitadel.riotpiao.com", true},
|
||||
{"https://zitadel.riotpiao.com/oauth/v2", "https://zitadel.riotpiao.com", true},
|
||||
{"https://evil.example.com", "https://zitadel.riotpiao.com", false},
|
||||
{"https://zitadel.riotpiao.com.evil.com", "https://zitadel.riotpiao.com", false},
|
||||
{"anything", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := issuerMatches(c.iss, c.configured); got != c.want {
|
||||
t.Errorf("issuerMatches(%q, %q) = %v, want %v", c.iss, c.configured, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimsFromIntrospection_Nil(t *testing.T) {
|
||||
if got := claimsFromIntrospection(nil); got == nil || len(got) != 0 {
|
||||
t.Errorf("expected empty non-nil claims, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudienceValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
claim any
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"string", "api-gw", 1, true},
|
||||
{"empty string", "", 1, false},
|
||||
{"string slice", []string{"a", "b"}, 2, true},
|
||||
{"iface slice", []interface{}{"a", 5}, 1, true},
|
||||
{"missing", nil, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
claims := jwt.MapClaims{}
|
||||
if c.claim != nil {
|
||||
claims["aud"] = c.claim
|
||||
}
|
||||
got, ok := audienceValues(claims)
|
||||
if ok != c.ok || len(got) != c.want {
|
||||
t.Errorf("%s: audienceValues = (%v, %v), want len %d ok %v", c.name, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
_ = fmt.Sprint()
|
||||
}
|
||||
Reference in New Issue
Block a user