Accept Tessera (Keycloak-compatible) OIDC tokens as API bearer

Adds an additive bearer-verification path: verify RS256 access tokens against
Tessera's JWKS (iss/aud/exp), map sub/preferred_username/email + roles
(realm_access.roles, resource_access.<audience>.roles) to the app's identity.
Existing auth (API keys / app JWTs / sessions) is unchanged. Issuer + audience
are env-configurable. Validated end-to-end against the local sim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-06-02 15:11:30 +01:00
parent b02b1706b6
commit 7b2f2fae30
3 changed files with 213 additions and 0 deletions

View File

@@ -158,6 +158,7 @@ func Mount(cfg *config.Config, db *sqlx.DB, oidcSvc *oidc.Service, version strin
// to do with anonymous (typically: serve public subset, hide private).
func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
tessera := auth.TesseraBearer(cfg.OIDCBearerIssuer, cfg.OIDCBearerAudience)
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -172,6 +173,14 @@ func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVer
if rw.status != http.StatusUnauthorized {
return
}
// Not an agent key (opaque) → try a Tessera (external OIDC)
// bearer JWT. Opaque agent keys fail JWT parse here and 401,
// which we again demote to "fall through".
rw = &captureWriter{ResponseWriter: w}
tessera(next).ServeHTTP(rw, r)
if rw.status != http.StatusUnauthorized {
return
}
}
// Try OIDC (session cookie) — always (no header gate needed,
// cookie presence is implicit) so an authenticated browser
@@ -192,6 +201,7 @@ func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVer
// requireAnyAuthChain: 401 if neither agent nor user auth succeeds.
func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
tessera := auth.TesseraBearer(cfg.OIDCBearerIssuer, cfg.OIDCBearerAudience)
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -201,6 +211,12 @@ func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionV
if rw.status != http.StatusUnauthorized {
return
}
// Not an agent key → try a Tessera (external OIDC) bearer JWT.
rw = &captureWriter{ResponseWriter: w}
tessera(next).ServeHTTP(rw, r)
if rw.status != http.StatusUnauthorized {
return
}
}
oidcMw(next).ServeHTTP(w, r)
})