// Package auth authenticates client requests against the key store (plan §7.9). // // The rule that shapes everything here: authentication runs on every request, // and the request path does not touch the database. A key is looked up once, // resolved against its tenancy chain once, and then answered from memory until // the entry expires or is invalidated. package auth import ( "context" "errors" "net/http" "strings" "sync" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/store" "golang.org/x/sync/singleflight" ) // Principal is who a request is, once authenticated. type Principal struct { KeyID string KeyPrefix string // UserID is the person the key belongs to, empty for a key an operator // issued in a project. A personal key has a person and no project. UserID string ProjectID string TeamID string OrgID string // Band and RoutingProfile come from the key, and are authoritative: with // authentication on, a client can no longer promote itself with a header. Band string RoutingProfile string Limits store.Effective } // Allows reports whether this principal may use a model. func (p Principal) Allows(model string) bool { return p.Limits.Allows(model) } type contextKey struct{} // WithPrincipal attaches an authenticated identity to a context. func WithPrincipal(ctx context.Context, p Principal) context.Context { return context.WithValue(ctx, contextKey{}, p) } // FromContext returns the authenticated identity, if there is one. // // Absent means authentication is off, which is a deployment choice rather than // a failure: a gateway on a trusted network with no keys configured still // serves. Callers must therefore treat the missing case as "unrestricted", and // the middleware is what guarantees it cannot mean "unchecked". func FromContext(ctx context.Context) (Principal, bool) { p, ok := ctx.Value(contextKey{}).(Principal) return p, ok } // Config tunes the authenticator. type Config struct { // TTL is how long a resolved key is trusted before being read again. It // bounds how stale a revocation can be, so it trades a database read per // key per TTL against how long a revoked key keeps working. TTL time.Duration // NegativeTTL is how long a rejected key is remembered as rejected, so a // client looping with a bad key cannot turn into a database read per // request. NegativeTTL time.Duration // TouchInterval is how often a key's last-used time is written, at most. TouchInterval time.Duration } // DefaultConfig returns sensible values. func DefaultConfig() Config { return Config{TTL: 30 * time.Second, NegativeTTL: 5 * time.Second, TouchInterval: time.Minute} } func (c *Config) normalize() { d := DefaultConfig() if c.TTL <= 0 { c.TTL = d.TTL } if c.NegativeTTL <= 0 { c.NegativeTTL = d.NegativeTTL } if c.TouchInterval <= 0 { c.TouchInterval = d.TouchInterval } } // Errors an authenticator returns. They are distinguished because the status // codes differ: a missing credential is 401, a forbidden model is 403. var ( ErrMissing = errors.New("no api key") ErrInvalid = errors.New("invalid api key") ErrExpired = errors.New("api key expired") ErrRevoked = errors.New("api key revoked") // ErrThrottled is a client presenting more unknown keys than it is // allowed to have looked up. ErrThrottled = errors.New("too many unrecognised api keys from this address") ) // Authenticator resolves keys and caches the result. type Authenticator struct { store store.Store cfg Config now func() time.Time mu sync.RWMutex cache map[string]entry // touch batches last-used timestamps; nothing reads the field in real time // and writing it per request would put a database write back on the path. touchMu sync.Mutex touch map[string]time.Time // lookups rations the database reads of keys not in the cache, per // client address. lookups *buckets // resolving shares one database read between the requests that miss the // cache for the same key at once: when a busy key's entry expired, every // request in flight missed together, each took from the client's ration, // and a valid key was told to slow down. resolving singleflight.Group } type entry struct { principal Principal err error expires time.Time } // New builds an authenticator over a store. func New(s store.Store, cfg Config) *Authenticator { cfg.normalize() return &Authenticator{ store: s, cfg: cfg, now: time.Now, cache: map[string]entry{}, touch: map[string]time.Time{}, lookups: newBuckets(20, 50), } } // Authenticate resolves a presented credential. func (a *Authenticator) Authenticate(ctx context.Context, presented string) (Principal, error) { return a.AuthenticateFrom(ctx, presented, "") } // AuthenticateFrom resolves a credential presented by client, an address: // a key not in the cache costs a database read, so those are rationed per // client, and one that has made too many is told to slow down rather than // read for. Empty client is not rationed. func (a *Authenticator) AuthenticateFrom(ctx context.Context, presented, client string) (Principal, error) { if presented == "" { return Principal{}, ErrMissing } // Shape first: a bearer token from another system is rejected without a // lookup, and never reaches the store. if err := store.ParseKey(presented); err != nil { return Principal{}, ErrInvalid } hash := store.HashKey(presented) id := string(hash) if e, ok := a.lookup(id); ok { if e.err == nil { a.markUsed(e.principal.KeyID) } return e.principal, e.err } // One read for every request missing this key at once, rationed once. v, err, _ := a.resolving.Do(id, func() (any, error) { if e, ok := a.lookup(id); ok { // filled while this waited its turn return e, nil } if client != "" && !a.lookups.allow(client, a.now()) { return nil, ErrThrottled } p, err := a.resolve(ctx, hash) if err != nil && !isRejection(err) { // A database problem is not an authentication answer, so it must // not be cached as one. Failing the request is right — the // alternative is serving traffic whose authorization is unknown. return nil, err } ttl := a.cfg.TTL if err != nil { ttl = a.cfg.NegativeTTL } e := entry{principal: p, err: err, expires: a.now().Add(ttl)} a.store_(id, e) return e, nil }) if err != nil { return Principal{}, err } e := v.(entry) if e.err == nil { a.markUsed(e.principal.KeyID) } return e.principal, e.err } func (a *Authenticator) resolve(ctx context.Context, hash []byte) (Principal, error) { k, err := a.store.KeyByHash(ctx, hash) if errors.Is(err, store.ErrNotFound) { return Principal{}, ErrInvalid } if err != nil { return Principal{}, err } switch { case !k.RevokedAt.IsZero(): return Principal{}, ErrRevoked case !k.ExpiresAt.IsZero() && !a.now().Before(k.ExpiresAt): return Principal{}, ErrExpired } eff, err := a.store.Resolve(ctx, k.ID) if errors.Is(err, store.ErrOwnerInactive) { // Said as a revoked key is: whether the key exists is not the // caller's to learn. return Principal{}, ErrRevoked } if err != nil { return Principal{}, err } return Principal{ KeyID: k.ID, KeyPrefix: k.Prefix, UserID: eff.UserID, ProjectID: eff.ProjectID, TeamID: eff.TeamID, OrgID: eff.OrgID, Band: eff.Band, RoutingProfile: eff.RoutingProfile, Limits: eff, }, nil } func (a *Authenticator) lookup(id string) (entry, bool) { a.mu.RLock() e, ok := a.cache[id] a.mu.RUnlock() if !ok || a.now().After(e.expires) { return entry{}, false } return e, true } // maxCached bounds the cache. Every well-formed key anyone presents is an // entry, a rejected one included, so without a bound a stream of made-up keys // grows it until the process runs out of memory. const maxCached = 100_000 func (a *Authenticator) store_(id string, e entry) { a.mu.Lock() defer a.mu.Unlock() if len(a.cache) >= maxCached { now := a.now() for k, v := range a.cache { if now.After(v.expires) { delete(a.cache, k) } } // Still full of live entries: half of them go, which costs only a // lookup each for the keys in use. if len(a.cache) >= maxCached { n := 0 for k := range a.cache { if n%2 == 0 { delete(a.cache, k) } n++ } } } a.cache[id] = e } // Invalidate drops every cached decision. // // Coarse on purpose: a limit change anywhere in the tree can change any key // below it, and working out which keys those are costs more than re-resolving // the few that are actually in use. func (a *Authenticator) Invalidate() { a.mu.Lock() a.cache = map[string]entry{} a.mu.Unlock() } func (a *Authenticator) markUsed(keyID string) { if keyID == "" { return } now := a.now() a.touchMu.Lock() if last, ok := a.touch[keyID]; !ok || now.Sub(last) >= a.cfg.TouchInterval { a.touch[keyID] = now } a.touchMu.Unlock() } // FlushTouches writes the pending last-used timestamps. The batch writer calls // it on its own schedule. func (a *Authenticator) FlushTouches(ctx context.Context) error { a.touchMu.Lock() pending := a.touch a.touch = map[string]time.Time{} a.touchMu.Unlock() if len(pending) == 0 { return nil } return a.store.TouchKeys(ctx, pending) } func isRejection(err error) bool { return errors.Is(err, ErrInvalid) || errors.Is(err, ErrExpired) || errors.Is(err, ErrRevoked) || errors.Is(err, ErrMissing) } // BearerToken extracts the credential from a request. // // Both spellings are accepted because both are in the wild: the OpenAI SDKs // send Authorization, and some tools only manage to set an api-key header. func BearerToken(r *http.Request) string { if h := r.Header.Get("Authorization"); h != "" { if token, ok := strings.CutPrefix(h, "Bearer "); ok { return strings.TrimSpace(token) } // A bare value is common enough in hand-written clients to accept. return strings.TrimSpace(h) } if k := r.Header.Get("api-key"); k != "" { return strings.TrimSpace(k) } // Anthropic's clients send their key here. return strings.TrimSpace(r.Header.Get("x-api-key")) }