Files
llmgw/internal/auth/middleware.go
Ilker ManapandClaude Opus 5.5 fe5b7f88f0 Believe X-Forwarded-* only from router.trusted_proxies
A new internal/clientip says who sent a request and how: the connection's
address, scheme and Host, or through a configured proxy what it forwarded.
The base URL in pages and setup scripts, isHTTPS for cookies, and the
per-address limits of auth and the sign-in forms use it. /setup scripts are
no-store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:29:02 +02:00

116 lines
3.9 KiB
Go

package auth
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
)
// Middleware authenticates every request before it reaches the router.
//
// It is a wrapper rather than a check inside the handler so there is no path
// into the API that forgets to call it. Adding a route cannot accidentally add
// an unauthenticated one.
func Middleware(a *Authenticator, log *slog.Logger, exempt ...string) func(http.Handler) http.Handler {
if log == nil {
log = slog.Default()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isExempt(r.URL.Path, exempt) {
next.ServeHTTP(w, r)
return
}
p, err := a.AuthenticateFrom(r.Context(), BearerToken(r), clientAddr(r))
if err != nil {
writeAuthError(w, err)
// The key prefix, never the key: a rejected credential in a log
// is still a credential.
log.Debug("rejected a request", "path", r.URL.Path,
"reason", err, "remote", r.RemoteAddr)
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
})
}
}
// isExempt reports whether a path is served without a key.
//
// An entry ending in "/" is a prefix rule, with one exception that matters
// enormously: "/" itself is exact. Treating it as a prefix would match every
// path there is, so exempting the landing page would silently exempt the whole
// API — authentication still installed, still logging, and letting everything
// through.
//
// Metrics and pprof are not in this list because they live on the admin
// listener, a separate port precisely so it can be firewalled rather than
// authenticated.
func isExempt(path string, exempt []string) bool {
for _, e := range exempt {
if path == e {
return true
}
if e != "/" && strings.HasSuffix(e, "/") && strings.HasPrefix(path, e) {
return true
}
}
return false
}
func writeAuthError(w http.ResponseWriter, err error) {
status, kind, msg := http.StatusUnauthorized, "invalid_request_error", "invalid api key"
switch {
case errors.Is(err, ErrMissing):
msg = "no api key was provided"
case errors.Is(err, ErrThrottled):
w.Header().Set("Retry-After", "1")
status, kind, msg = http.StatusTooManyRequests, "rate_limit_exceeded", "too many unrecognised api keys; slow down"
case errors.Is(err, ErrExpired):
msg = "this api key has expired"
case errors.Is(err, ErrRevoked):
// Deliberately the same message as an unknown key. Telling a caller
// that a key exists but was revoked confirms the key was real.
msg = "invalid api key"
case errors.Is(err, ErrInvalid):
default:
// A store failure is ours, not the caller's, and must not read as a
// rejected credential — that would send someone hunting for a key
// problem that does not exist.
status, kind, msg = http.StatusServiceUnavailable, "server_error",
"authentication is temporarily unavailable"
}
if status == http.StatusUnauthorized {
// What the OpenAI SDKs look for to stop retrying.
w.Header().Set("WWW-Authenticate", `Bearer realm="llmgw"`)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"message": msg, "type": kind, "code": nil,
}})
}
// WriteForbiddenModel reports a model this key may not use.
//
// Separate from authentication because it happens later: the model is only
// known once the body has been parsed.
func WriteForbiddenModel(w http.ResponseWriter, model string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"message": "this api key is not permitted to use model " + model,
"type": "invalid_request_error", "code": nil,
}})
}
// clientAddr is the address a request came from: through a trusted proxy,
// the client's.
func clientAddr(r *http.Request) string { return clientip.Addr(r) }