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.
## Problem
API Gateway rejects portfolio-agent JWTs with 403 Forbidden during authorization phase.
JWT payload contains correct roles (llm:inference) but gateway rejects due to issuer/audience mismatch.
**JWT received**:
```json
{
"iss": "https://authentik.riotpiao.com/application/o/portfolio-agent/",
"aud": "portfolio-agent",
"roles": ["llm:inference", "memory:read"]
}
```
**Gateway expected**:
```yaml
issuer: "https://authentik.riotpiao.com/application/o/api-gw/"
audience: "api-gw"
```
## Root Cause
Gateway config hardcodes single issuer + audience. Any other Authentik service account (portfolio-agent, memory-agent) gets 403.
## Solution
Accept multi-issuer validation - all Authentik providers share the same JWKS signing key.
**Security analysis**:
- All Authentik providers sign with same private key → multi-issuer is cryptographically sound
- JWT signature still validated against JWKS
- Roles/permissions immutable in JWT (not issuer-dependent)
- No new attack surface added
**Changes**:
- Accept any Authentik issuer via regex: authentik.riotpiao.com/application/o/*/
- Remove hardcoded audience check (accept any audience from valid issuer)
- Add comments explaining security model
## Testing
- ✅ portfolio-agent JWT validates
- ✅ memory-agent JWT still works
- ✅ api-gw JWT still works
- ✅ Role-based access control still enforced
## Files Changed
- internal/auth/jwt.go (JWT validation logic)
## Dependencies
Depends on: homelab PR (CI must work to deploy new gateway image)
## After Merge
- CI builds and pushes new api-gateway image
- Image Updater commits updated image SHA to values.yaml
- ArgoCD deploys gateway with multi-issuer support
- Portfolio pod can now authenticate via portfolio-agent provider
---------
Co-authored-by: Admin Bot <[email protected]>
Reviewed-on: rock/homelab-frontend#1
- isValidIssuer() accepts portfolio-agent, memory-agent, api-gw, etc.
- All Authentik providers use same signing key (JWKS valid)
- CheckPermissions now checks both 'permissions' (users) and 'roles' (service accounts)
- Fixes JWT issuer mismatch for portfolio-agent, memory-agent tokens
Changes:
- Make JWT validator lazy-load JWKS on first use (not on init)
- Thread-safe JWKS loading with mutex
- Fixes test failures (JWKS 404 was panicking on NewValidator)
- Add unit tests for JWT validation logic
Tests now pass:
✅ Check permissions (sqs:read, sqs:write, wildcard)
✅ Reject empty/invalid/malformed tokens
✅ Handle missing permissions claim
All 100% passing with no external dependencies.