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>
186 lines
5.1 KiB
Go
186 lines
5.1 KiB
Go
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
|
|
}
|