Files
llmgw/internal/auth/auth.go
T
Ilker ManapandClaude Opus 5 45c8cd23a3 Close the gateway: API keys, tenancy and model allowlists
Until now anything that could reach the client port could use every model in
the catalog. A key now resolves to an identity, and that identity decides what
it may do.

Authentication wraps the whole mux rather than checking inside each handler,
so adding a route cannot add an unauthenticated one by omission. Health stays
open, because a load balancer has no key and a health check that needs one
reports the gateway down.

Nothing on the request path reads the database. A key is resolved once and
answered from memory until the entry expires; rejections are cached too, so a
client looping with a bad key cannot turn into a database read per request.
The price is that a revocation takes up to cache_ttl to take effect, which is
the trade stated in the configuration next to the knob. A store failure is
not an authentication answer: it is never cached as one and returns 503, not
401 — sending someone hunting for a key problem that does not exist wastes the
one thing an incident is short of. A revoked key is refused with the same
message as an unknown one, because saying otherwise confirms the key was real.

With auth on the band comes from the key and the band header is ignored. The
header was trusted only while there was nothing to consult, and it let any
client promote itself into the top priority band. Tenant fairness within a
band is now per key rather than per session, so one caller cannot take a band
by opening many sessions.

Allowlists are decided per model, not per string: a model's canonical name and
every alias name the same thing, so either form works in an allowlist and no
alias can reach a model that was not allowed. The first version of that test
passed without the resolution being there at all, which is why it now checks
all four combinations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:44:33 +02:00

268 lines
7.3 KiB
Go

// 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"
)
// Principal is who a request is, once authenticated.
type Principal struct {
KeyID string
KeyPrefix 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")
)
// 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
}
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{},
}
}
// Authenticate resolves a presented credential.
func (a *Authenticator) Authenticate(ctx context.Context, presented 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
}
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 Principal{}, err
}
ttl := a.cfg.TTL
if err != nil {
ttl = a.cfg.NegativeTTL
}
a.store_(id, entry{principal: p, err: err, expires: a.now().Add(ttl)})
if err == nil {
a.markUsed(p.KeyID)
}
return p, 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 err != nil {
return Principal{}, err
}
return Principal{
KeyID: k.ID, KeyPrefix: k.Prefix, 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
}
func (a *Authenticator) store_(id string, e entry) {
a.mu.Lock()
a.cache[id] = e
a.mu.Unlock()
}
// 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)
}
return strings.TrimSpace(r.Header.Get("api-key"))
}