Files
llmgw/internal/auth/middleware.go
T
Ilker ManapandClaude Opus 5 1839832b8b
CI / test (push) Successful in 1m2s
Add a landing page and a read-only cluster dashboard
Pasting the gateway's address into a browser returned 404, which looks like a
broken endpoint rather than one that tells you how to use it.

Two pages, and the split between them is a security boundary rather than a
layout choice. The landing page is served without a key — it has to be,
because the person reading it is the one who does not have one yet — so it
contains no data at all: it says how to connect, not what is here. Model names
stay behind /v1/models. It reconstructs the address from the forwarded headers
so the copy-pasteable example names the address the client actually used
rather than the internal one; those headers are reflected into displayed text
and never used for a decision.

The dashboard shows nodes, models, replicas, placement failures and usage, and
lives on the admin listener only. That listener is loopback-bound and also
serves pprof, which can dump the process heap — where API keys sit in
plaintext — so reaching the page from elsewhere is an SSH tunnel's job. It is
read-only: anything that mints a credential stays in `llmgw keys`, where it
needs a shell rather than a browser session this project has no way to
authenticate.

Mounting the landing page uncovered a hole in the exemption list. An entry
ending in "/" was a prefix rule, and "/" ends in "/", so exempting the landing
page would have exempted every path there is — authentication still installed,
still logging, and letting the whole API through. "/" is now exact, with a
test enumerating what must and must not be exempt.

Server-rendered templates and one stylesheet, embedded. The single-binary goal
rules out a JavaScript toolchain and nothing on either page needs one.

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

107 lines
3.5 KiB
Go

package auth
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
)
// 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.Authenticate(r.Context(), BearerToken(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, 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,
}})
}