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,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