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

@@ -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
// <jwt>` 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
}

View File

@@ -71,6 +71,16 @@ type Config struct {
OIDCIssuer string OIDCIssuer string
OIDCClientID 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 <jwt>` 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 // OIDC_ONLY: when "true", disables the dev-bypass auth path on
// every browser-facing route. Use this in prod once the OIDC // every browser-facing route. Use this in prod once the OIDC
// realm + client are configured so a leaked dev token can't // 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"), DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"),
OIDCIssuer: os.Getenv("OIDC_ISSUER"), OIDCIssuer: os.Getenv("OIDC_ISSUER"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), 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", OIDCOnly: os.Getenv("OIDC_ONLY") == "true",
SessionSigningKey: os.Getenv("SESSION_SIGNING_KEY"), SessionSigningKey: os.Getenv("SESSION_SIGNING_KEY"),
} }

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). // 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 { func optionalAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper) agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
tessera := auth.TesseraBearer(cfg.OIDCBearerIssuer, cfg.OIDCBearerAudience)
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly) oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 { if rw.status != http.StatusUnauthorized {
return 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, // Try OIDC (session cookie) — always (no header gate needed,
// cookie presence is implicit) so an authenticated browser // 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. // 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 { func requireAnyAuthChain(db *sqlx.DB, cfg *config.Config, verifier auth.SessionVerifier) func(http.Handler) http.Handler {
agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper) agent := auth.AgentAPIKey(db, cfg.AgentAPIKeyPepper)
tessera := auth.TesseraBearer(cfg.OIDCBearerIssuer, cfg.OIDCBearerAudience)
oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly) oidcMw := auth.OIDCBrowser(verifier, cfg.IsDev(), cfg.OIDCDevBypassToken, cfg.OIDCOnly)
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 { if rw.status != http.StatusUnauthorized {
return 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) oidcMw(next).ServeHTTP(w, r)
}) })