From 7b2f2fae3085bdb05373448a1b8f43a0ce92d8ff Mon Sep 17 00:00:00 2001 From: hzhang Date: Tue, 2 Jun 2026 15:11:30 +0100 Subject: [PATCH] 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..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) --- internal/auth/oidc_bearer.go | 185 +++++++++++++++++++++++++++++++++++ internal/config/config.go | 12 +++ internal/httpapi/routes.go | 16 +++ 3 files changed, 213 insertions(+) create mode 100644 internal/auth/oidc_bearer.go diff --git a/internal/auth/oidc_bearer.go b/internal/auth/oidc_bearer.go new file mode 100644 index 0000000..6965877 --- /dev/null +++ b/internal/auth/oidc_bearer.go @@ -0,0 +1,185 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +var ( + errNoSub = errors.New("token missing sub") + errBadAudience = errors.New("token audience does not match") +) + +// TesseraBearer middleware: accepts access tokens issued by the external +// "Tessera" OIDC provider (Keycloak-compatible) as API bearer tokens. +// +// This is ADDITIVE to the existing agent-key and browser-session auth +// paths. It only acts when the request carries `Authorization: Bearer +// ` AND that bearer is a parseable, verifiable Tessera JWT. Agent +// keys are opaque (not JWTs), so they fail verification here and the +// chain falls through to the next auth step — nothing breaks. +// +// On success it attaches a CallerUser: +// +// Caller{ +// Kind: CallerUser, +// ID: sub, +// Email: email, +// Name: preferred_username, +// Roles: realm_access.roles ++ resource_access[audience].roles, +// } +// +// On any failure (missing/opaque bearer, bad signature, wrong issuer, +// wrong audience, expired) it 401s — the caller composes this into the +// auth chain the same way as AgentAPIKey (capture 401 → fall through). +func TesseraBearer(issuer, audience string) func(http.Handler) http.Handler { + v := newTesseraVerifier(issuer, audience) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := bearerToken(r) + if raw == "" { + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + caller, err := v.verify(r.Context(), raw) + if err != nil { + http.Error(w, "invalid tessera bearer", http.StatusUnauthorized) + return + } + ctx := WithCaller(r.Context(), caller) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// tesseraVerifier wraps a go-oidc IDTokenVerifier over the issuer's JWKS. +// The provider (and thus its cached KeySet) is lazily fetched on first +// use and refreshed hourly — same defensive TTL the OIDC login service +// uses. go-oidc's RemoteKeySet handles per-kid JWKS fetching + caching +// internally; this wrapper just adds the discovery cache + claim mapping. +type tesseraVerifier struct { + issuer string + audience string + + mu sync.Mutex + verifier *oidc.IDTokenVerifier + fetchedAt time.Time +} + +func newTesseraVerifier(issuer, audience string) *tesseraVerifier { + return &tesseraVerifier{issuer: issuer, audience: audience} +} + +func (t *tesseraVerifier) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.verifier != nil && time.Since(t.fetchedAt) < time.Hour { + return t.verifier, nil + } + p, err := oidc.NewProvider(ctx, t.issuer) + if err != nil { + return nil, err + } + // SkipClientIDCheck: Keycloak access tokens carry the app client id in + // `aud`, but go-oidc's default aud check compares against ClientID. We + // do the audience check ourselves in verify() so we can accept aud as + // either a string or an array, so disable the built-in one here. + v := p.Verifier(&oidc.Config{ + SkipClientIDCheck: true, + SupportedSigningAlgs: []string{oidc.RS256}, + }) + t.verifier = v + t.fetchedAt = time.Now() + return v, nil +} + +// tesseraClaims is the projection of a Tessera/Keycloak access token we +// read. aud is decoded loosely (string or array) via audience. +type tesseraClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Aud audience `json:"aud"` + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` + ResourceAccess map[string]struct { + Roles []string `json:"roles"` + } `json:"resource_access"` +} + +func (t *tesseraVerifier) verify(ctx context.Context, raw string) (Caller, error) { + v, err := t.getVerifier(ctx) + if err != nil { + return Caller{}, err + } + // Verify() checks RS256 signature against JWKS, iss == issuer, and exp. + tok, err := v.Verify(ctx, raw) + if err != nil { + return Caller{}, err + } + var c tesseraClaims + if err := tok.Claims(&c); err != nil { + return Caller{}, err + } + if c.Sub == "" { + return Caller{}, errNoSub + } + if !c.Aud.contains(t.audience) { + return Caller{}, errBadAudience + } + roles := append([]string{}, c.RealmAccess.Roles...) + if ra, ok := c.ResourceAccess[t.audience]; ok { + roles = append(roles, ra.Roles...) + } + name := c.PreferredUsername + return Caller{ + Kind: CallerUser, + ID: c.Sub, + Email: c.Email, + Name: name, + Roles: roles, + }, nil +} + +// audience decodes the JWT `aud` claim, which may be a single string or +// an array of strings. +type audience []string + +func (a *audience) UnmarshalJSON(b []byte) error { + b = []byte(strings.TrimSpace(string(b))) + if len(b) == 0 || string(b) == "null" { + *a = nil + return nil + } + if b[0] == '[' { + var arr []string + if err := json.Unmarshal(b, &arr); err != nil { + return err + } + *a = arr + return nil + } + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *a = []string{s} + return nil +} + +func (a audience) contains(want string) bool { + for _, v := range a { + if v == want { + return true + } + } + return false +} diff --git a/internal/config/config.go b/internal/config/config.go index cdcb6e4..65bafc2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,6 +71,16 @@ type Config struct { OIDCIssuer string OIDCClientID string + // OIDCBearerIssuer + OIDCBearerAudience configure acceptance of + // access tokens issued by the external "Tessera" OIDC provider + // (Keycloak-compatible) as API bearer tokens. ADDITIVE to the + // existing agent-key + browser-session auth paths. A request with + // `Authorization: Bearer ` whose JWT verifies against this + // issuer's JWKS and carries this audience is treated as a CallerUser. + // Env: OIDC_BEARER_ISSUER, OIDC_BEARER_AUDIENCE. + OIDCBearerIssuer string + OIDCBearerAudience string + // OIDC_ONLY: when "true", disables the dev-bypass auth path on // every browser-facing route. Use this in prod once the OIDC // realm + client are configured so a leaked dev token can't @@ -112,6 +122,8 @@ func LoadFromEnv() (*Config, error) { DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"), OIDCIssuer: os.Getenv("OIDC_ISSUER"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCBearerIssuer: getenv("OIDC_BEARER_ISSUER", "https://login.hangman-lab.top/realms/Hangman-Lab"), + OIDCBearerAudience: getenv("OIDC_BEARER_AUDIENCE", "dialectic-prod"), OIDCOnly: os.Getenv("OIDC_ONLY") == "true", SessionSigningKey: os.Getenv("SESSION_SIGNING_KEY"), } diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 8fd5aff..cd7d0cb 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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) })