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 }