Auth und RequestContext — wir machen das Tor zu

Auth und RequestContext — wir machen das Tor zu

Das Gateway aus Artikel 4 ist funktional, aber offen: jeder Request kommt durch, Fehler landen als unstrukturiertes JSON auf dem Client, und niemand wird gebremst wenn er in einer Schleife Anfragen schickt. Für einen produktiven Einsatz — oder auch nur für den Test mit mehreren API-Keys — braucht es Auth, Rate-Limiting und Fehlerformate die den jeweiligen Spec-Erwartungen entsprechen. Anthropic-Clients erwarten {"type":"error","error":{"type":"..."}}, OpenAI-Clients erwarten {"error":{"message":"...","param":null,"code":null}}. Wir liefern beiden was sie brauchen, und tun das mit dem generischen RequestContext aus Hummingbird 2 — dem Feature, das wir im Prolog angekündigt und bis jetzt nicht gebraucht haben.

Generic RequestContext

Hummingbird 2 macht Router und Application generisch über den Request-Kontext. Bisher haben wir BasicRequestContext verwendet, der nichts weiter trägt als Hummingbirds interne Infrastruktur. In diesem Artikel definieren wir GatewayRequestContext und hängen zwei Properties daran: den authentifizierten API-Key und den zugehörigen Tenant-Namen.

import Hummingbird

struct GatewayRequestContext: RequestContext {
    var coreContext: CoreRequestContextStorage

    var apiKey: String?
    var tenant: String?

    init(source: ApplicationRequestContextSource) {
        self.coreContext = .init(source: source)
        self.apiKey = nil
        self.tenant = nil
    }
}

CoreRequestContextStorage ist Hummingbirds Standard-Speicher für Logger, Decoder und Framework-interne Daten; wir lagern ihn aus statt ihn zu ersetzen, damit das Framework seine eigenen Invarianten weiterhin durchsetzen kann. Die eigenen Properties sind schlichte Optionals, die wir im init explizit auf nil setzen.

Middleware trägt Werte in den Kontext, indem sie eine modifizierte Kopie an next übergibt:

var context = context
context.tenant = tenant
return try await next(request, context)

Weil GatewayRequestContext ein Struct ist, erzeugt das eine neue Kopie — kein Shared-State, keine Locks, kein Nachdenken über Concurrent Access. Vapor löst denselben Bedarf mit request.storage[MyKey.self] = value, einem untyped Dictionary, das weder Laufzeit-Garantien noch Compiler-Prüfungen über den gespeicherten Typ gibt. Hummingbirds Ansatz ist expliziter und lässt uns falsch verdrahtete Middleware-Ketten zur Compile-Zeit statt zur Laufzeit entdecken.

API-Key-Authentifizierung

Wir akzeptieren Keys über zwei Header-Konventionen: x-api-key (Anthropic-Stil) und Authorization: Bearer (OpenAI-Stil). Einer reicht, der erste gefundene gewinnt.

static func extractKey(from request: Request) -> String? {
    if let name = HTTPField.Name("x-api-key"),
       let value = request.headers[name], !value.isEmpty {
        return value
    }
    if let auth = request.headers[.authorization],
       auth.hasPrefix("Bearer ") {
        let key = String(auth.dropFirst("Bearer ".count))
            .trimmingCharacters(in: .whitespaces)
        return key.isEmpty ? nil : key
    }
    return nil
}

Der "Bearer " Prefix-Check mit nachfolgendem Leerzeichen schließt Strings wie Bearer-x stillschweigend aus — kein expliziter Format-Error nötig, weil ein fehlgeformter Authorization-Header schlicht keinen Key liefert und die Anfrage dann als unauthentifiziert behandelt wird.

Constant-Time-Compare

Ein naiver String-Vergleich (==) bricht ab, sobald er ein abweichendes Byte findet. Bei einer ausreichend großen Anzahl von Anfragen kann ein Angreifer aus den Latenzdifferenzen ableiten, wie viele Bytes seines Kandidaten mit dem richtigen Key übereinstimmen. Das ist kein theoretisches Problem, sondern ein klassischer Timing-Angriff der gegen Remote-Endpoints demonstriert wurde.

Wir vergleichen daher nicht den String direkt, sondern seinen SHA-256-Hash — und das byte-by-byte über XOR-Akkumulation:

static func constantTimeEquals(_ a: Data, _ b: Data) -> Bool {
    guard a.count == b.count else { return false }
    var diff: UInt8 = 0
    for i in 0..<a.count {
        diff |= a[i] ^ b[i]
    }
    return diff == 0
}

Beide Inputs sind SHA-256-Hashes (immer 32 Byte), deshalb ist die Längenprüfung hier kein Timing-Leak: ein Angreifer kann aus a.count != b.count nichts ableiten, weil beide Inputs immer gleich lang sind. Das XOR-Akkumulieren lautet immer 32 Iterationen, unabhängig davon an welcher Position sich Keys unterscheiden.

Die konfigurierten Keys werden beim Start der Middleware einmal gehasht und in einem Dictionary [Data: String] abgelegt, sodass wir zur Laufzeit nur den eingehenden Key hashen müssen. Statt eines direkten Dictionary-Lookups iterieren wir über alle konfigurierten Hashes — damit hängt die Laufzeit vom Umfang der Key-Liste ab, nicht davon welcher Key matcht, was einen weiteren Timing-Kanal schließt.

SHA256 kommt aus swift-crypto. Auf macOS ist das ein Wrapper über CryptoKit, auf Linux über BoringSSL, aber das API ist in beiden Fällen identisch — unser Code braucht keine #if canImport(CryptoKit)-Weiche und kompiliert ohne Anpassung auf beiden Plattformen.

Tenant-Konfiguration

Keys werden als "tenant:key"-Paare konfiguriert, zum Beispiel "alice:sk-abc123,bob:sk-def456". Die Middleware parst diese Paare beim Init und speichert nur die Hashes — die Klartexte verlassen den Initialisierungsblock nie. Paare ohne Doppelpunkt oder mit leerem Tenant-Namen werden übersprungen. Nach erfolgreicher Authentifizierung steht der Tenant-Name in context.tenant, bereit für den Rate-Limiter der im nächsten Middleware-Schritt folgt.

Spec-konforme Error-Responses

Hummingbirds Default-Fehlerformat ist {"error":"..."}. Beide Specs weichen davon ab. Wir definieren GatewayError als eigenes Error-Enum:

enum GatewayError: Error {
    case unauthorized(String)
    case badRequest(String)
    case rateLimitExceeded(String, retryAfter: Int)
    case backendUnavailable(String)
    case backendError(String)
    case internalError(String)
}

Jeder Case trägt eine lesbare Message; der Rate-Limit-Case trägt zusätzlich die Retry-After-Sekunden als assoziiertes Label, damit die Middleware sie in den Response-Header schreiben kann ohne sie aus dem Error-String extrahieren zu müssen. Die Zuordnung zu HTTP-Status-Code und den protokollspezifischen Type-Strings liegt direkt im Enum:

CaseStatusAnthropic-TypeOpenAI-Type
unauthorized401authentication_errorauthentication_error
badRequest400invalid_request_errorinvalid_request_error
rateLimitExceeded429rate_limit_errorrate_limit_exceeded
backendUnavailable503overloaded_errorserver_error
backendError502api_errorserver_error
internalError500api_errorserver_error

Die GatewayErrorMiddleware fängt jeden GatewayError downstream und bestimmt das Format anhand des Request-Pfads:

struct GatewayErrorMiddleware: RouterMiddleware {
    typealias Context = GatewayRequestContext

    func handle(_ request: Request, context: Context,
                next: (Request, Context) async throws -> Response) async throws -> Response {
        do {
            return try await next(request, context)
        } catch let error as GatewayError {
            return Self.buildResponse(for: error, path: request.uri.path)
        }
    }
}

Pfade unter /v1/messages erhalten das Anthropic-Format, alle anderen das OpenAI-Format. Ein Pfad-Präfix-Vergleich ist robuster als Header-Sniffing, weil Clients manchmal beide Header setzen und die Protokollwahl besser aus der Route ablesbar ist als aus dem Accept-Header.

Eine Besonderheit beim OpenAI-Format: Swift’s JSONEncoder codiert nil-Optionals standardmäßig gar nicht — die Keys fehlen dann im JSON komplett. Die OpenAI-Spec verlangt aber explizit "param": null und "code": null. Wir implementieren daher encode(to:) von Hand und rufen encodeNil(forKey:) für diese beiden Properties auf, sodass sie immer im Output erscheinen.

Rate-Limiting per Token-Bucket

Der Token-Bucket-Algorithmus ist einfach und effektiv: jeder Tenant hat einen Bucket der Kapazität burst, der sich mit ratePerMinute / 60 Tokens pro Sekunde auffüllt. Ein Request kostet einen Token. Ist der Bucket leer, kommt ein 429.

Wir implementieren das als Swift actor, der ein Dictionary von Bucket-States pro Tenant verwaltet:

actor RateLimiter {
    private struct Bucket {
        var tokens: Double
        var lastRefill: Date
    }

    let capacity: Double
    let refillPerSecond: Double
    private var buckets: [String: Bucket] = [:]

    init(ratePerMinute: Int, burst: Int) {
        self.capacity = Double(max(1, burst))
        self.refillPerSecond = Double(max(1, ratePerMinute)) / 60.0
    }

    func consume(tenant: String) -> Int? {
        let now = Date()
        var bucket = buckets[tenant] ?? Bucket(tokens: capacity, lastRefill: now)

        let elapsed = max(0, now.timeIntervalSince(bucket.lastRefill))
        bucket.tokens = min(capacity, bucket.tokens + elapsed * refillPerSecond)
        bucket.lastRefill = now

        if bucket.tokens >= 1.0 {
            bucket.tokens -= 1.0
            buckets[tenant] = bucket
            return nil
        }

        buckets[tenant] = bucket
        let needed = 1.0 - bucket.tokens
        return max(1, Int((needed / refillPerSecond).rounded(.up)))
    }
}

Zwei Designentscheidungen sind hier erwähnenswert. Den Refill berechnen wir nicht in einem Hintergrund-Task der periodisch feuert, sondern direkt beim Consume: elapsed * refillPerSecond gibt die aufgelaufenen Tokens seit dem letzten Zugriff. Das hält den Actor frei von jeglichem Background-Lifecycle — kein Task.detached, das beim Herunterfahren explizit abgebrochen werden muss.

Neue Tenants starten mit einem vollen Bucket. Ein Client, der zum ersten Mal eine Anfrage stellt, verdient es nicht, sofort gebremst zu werden; erst nachdem er sein initiales Burst-Kontingent aufgebraucht hat, greift die Steady-State-Rate.

Der Retry-After-Wert ist eine konservative obere Schranke, abgerundet nach oben. Clients, die ihn einhalten, werden beim nächsten Versuch einen Token vorfinden; Clients, die früher retry-en, bekommen erneut 429 — was korrekt und von der RFC 6585 so vorgesehen ist.

Middleware-Verdrahtung

Der Router wechselt von BasicRequestContext auf GatewayRequestContext:

func buildRouter(
    mlxClient: MLXClient,
    modelID: String,
    keyPairs: [String],
    limiter: RateLimiter
) -> Router<GatewayRequestContext> {
    let router = Router(context: GatewayRequestContext.self)

    router.addMiddleware {
        LogRequestsMiddleware(.info)
        GatewayErrorMiddleware()
    }

    router.get("healthz") { _, _ in HTTPResponse.Status.ok }

    let authed = router.group("v1")
    if !keyPairs.isEmpty {
        authed
            .add(middleware: APIKeyAuthMiddleware(keyPairs: keyPairs))
            .add(middleware: RateLimitMiddleware(limiter: limiter))
    }

    authed.get("models") { ... }
    authed.post("messages") { ... }
    authed.post("chat/completions") { ... }

    return router
}

Die Reihenfolge in der Middleware-Chain ist bewusst gewählt. LogRequestsMiddleware kommt zuerst, damit auch fehlgeschlagene Auth-Versuche im Log auftauchen — ein Request, der mit 401 abgewiesen wird, ist genau der, den man im Incident-Fall sucht. GatewayErrorMiddleware sitzt direkt dahinter und umschließt alles Downstream, sodass jeder GatewayError aus Auth oder Rate-Limiting hier aufgefangen und formatiert wird. APIKeyAuthMiddleware setzt danach context.tenant, und RateLimitMiddleware folgt als letztes, weil sie den Tenant-Namen aus dem Context braucht.

/healthz liegt außerhalb der "v1"-Gruppe und läuft durch keine der Auth-Middlewares. Liveness-Probes von Kubernetes oder Docker-Compose sollen keinen API-Key mitschleppen müssen.

Wenn keyPairs leer ist — weil --api-keys "" übergeben wurde oder die Option ganz fehlt — installieren wir das Middleware-Paar nicht. Das Gateway verhält sich dann wie in Artikel 4: vollständig offen. Backward-Kompatibilität ohne Sonderfälle im Code.

Decode-Fehler aus dem Request-Body konvertieren wir explizit in GatewayError.badRequest:

let payload: ChatCompletionRequest
do {
    payload = try await request.decode(as: ChatCompletionRequest.self, context: context)
} catch {
    throw GatewayError.badRequest("Invalid request body: \(error.localizedDescription)")
}

Ohne diese Konvertierung würde ein Hummingbird-interner HTTPError unverändert durch GatewayErrorMiddleware durchfallen, weil die Middleware nur GatewayError fängt. Der Client würde dann ein unformatiertes JSON-Objekt erhalten, das weder dem Anthropic- noch dem OpenAI-Fehlerschema entspricht.

CLI-Konfiguration

App.swift bekommt drei neue Options:

@Option(help: "Komma-separierte tenant:key-Paare, z.B. 'alice:sk-abc,bob:sk-def'. Leer = Auth aus.")
var apiKeys: String = ""

@Option(help: "Rate-Limit pro Minute und Tenant")
var rateLimitPerMinute: Int = 60

@Option(help: "Burst-Kapazität für das Rate-Limit")
var rateLimitBurst: Int = 10

Ein Aufruf mit aktivierter Auth sieht so aus:

swift run gateway \
  --api-keys "alice:sk-abc123,bob:sk-def456" \
  --rate-limit-per-minute 60 \
  --rate-limit-burst 10

Mit 60 Requests pro Minute und einem Burst von 10 kann ein Tenant die ersten 10 Requests sofort stellen; danach wird er auf 1 Request pro Sekunde verlangsamt. Nach einer Pause von 10 Sekunden ist der Bucket wieder voll und der nächste Burst ist möglich.

Für Claude Code als Client reicht eine Umgebungsvariable:

export ANTHROPIC_API_KEY=alice:sk-abc123
export ANTHROPIC_BASE_URL=http://localhost:8080
claude

Claude Code übernimmt ANTHROPIC_API_KEY als x-api-key-Header — genau das, was APIKeyAuthMiddleware als erstes prüft.

Test mit curl

Gateway starten:

swift run gateway \
  --port 8090 \
  --api-keys "alice:sk-test-alice,bob:sk-test-bob" \
  --rate-limit-per-minute 6 \
  --rate-limit-burst 2

Rate 6/min und Burst 2 macht Fehler schnell reproduzierbar.

Kein Key — Anthropic-Pfad:

curl -s -X POST http://127.0.0.1:8090/v1/messages \
  -H "content-type: application/json" \
  -d '{"model":"x","max_tokens":10,"messages":[{"role":"user","content":"hi"}]}'
{"type":"error","error":{"type":"authentication_error","message":"Missing API key. Provide via x-api-key header or Authorization: Bearer."}}

Kein Key — OpenAI-Pfad:

curl -s -X POST http://127.0.0.1:8090/v1/chat/completions \
  -H "content-type: application/json" \
  -d '{"model":"x","messages":[{"role":"user","content":"hi"}]}'

Der Response lautet:

{"error":{"message":"Missing API key. Provide via x-api-key header or Authorization: Bearer.","type":"authentication_error","param":null,"code":null}}

Bearer-Konvention mit gültigem Key:

curl -s -o /dev/null -w "%{http_code}\n" \
  http://127.0.0.1:8090/v1/models \
  -H "authorization: Bearer sk-test-alice"

Reponse hier HTTP 200

200

Rate-Limit überschreiten:

for i in 1 2 3; do
  printf "req $i: "
  curl -s -o /dev/null -w "%{http_code}\n" \
    http://127.0.0.1:8090/v1/models \
    -H "x-api-key: sk-test-alice"
done
req 1: 200
req 2: 200
req 3: 429

Die 429-Response trägt einen Retry-After-Header mit der empfohlenen Wartezeit in Sekunden. Alice und Bob haben völlig getrennte Buckets; ein erschöpftes Alice-Kontingent hat keinen Einfluss auf Bobs Anfragen.

Healthcheck ohne Key:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8090/healthz
200

Streaming und Backend bleiben unberührt

Streaming aus Artikel 4 funktioniert ohne jede Änderung weiter. Auth und Rate-Limit laufen als Middleware, bevor der Route-Handler überhaupt eine Response committet. Sobald der Handler ein Response-Objekt zurückgibt, ist der HTTP-Status gesetzt und der Streaming-Body beginnt zu fließen — Middleware kann diesen Body nicht mehr unterbrechen. Ein Auth-Fehler tritt deshalb immer als 401 vor dem Stream-Start auf, nie mittendrin.

MLXClient, alle Anthropic- und OpenAI-Modell-Typen sowie StreamingResponses.swift bleiben komplett unberührt. Das Backend — ob mlx_lm.server auf macOS oder Ollama auf Linux — bekommt ausschließlich Requests, die bereits authentifiziert und gegen das Rate-Limit gezählt wurden. Aus seiner Sicht ist das Gateway weiterhin ein gewöhnlicher HTTP-Client.

Ausblick: Observability und Deployment

Das Gateway ist jetzt ein geschlossener Service. Was noch fehlt, ist Sichtbarkeit: ohne Metriken weiß man nicht, ob der Rate-Limit greift, wie hoch die Backend-Latenz ist, oder ob Fehler sich häufen. Artikel 6 widmet sich Observability: Prometheus-Metriken, strukturiertes Logging und was beim Cross-Compile mit dem Swift Static Linux SDK zu beachten ist — für wer das Gateway als Container deployen will.