Rustango docs
← Authentifizierung

JWT-Auth-API

Das Modul eigenständiges JWT signiert und verifiziert einen einzelnen Token. Eine echte API braucht den ganzen Lebenszyklus: einen kurzlebigen Access-Token, einen langlebigen Refresh-Token, Rotation beim Refresh und Widerruf für das Abmelden. Rustango liefert das als JwtLifecycle — und einen schlüsselfertigen Router, der POST /api/auth/login, /refresh, /logout und GET /me für Sie einhängt.

JWT-Auth-API: login stellt ein Access+Refresh-Paar aus, refresh rotiert und setzt den alten Token auf die Sperrliste, logout widerruft über einen JTI-Speicher

Quelle: rustango::tenancy::jwt_lifecycle (JwtLifecycle, JwtTokenPair, JwtClaims) und rustango::tenancy::auth_routes (jwt_router, Config) + rustango::jti_store (JtiStore, InMemoryJtiStore) — hinter jwt + tenancy.

Lauffähige Version: Die Token-Engine wird durch den getesteten auth_demo abgedeckt — cargo test -p auth_demo --test auth_jwt_api. Die HTTP-Endpunkte sind tenant-bezogen und werden durchgängig durch das eigene crates/rustango/tests/tenant_auth_live.rs des Frameworks geprüft.

Ein Begriff hier neu für Sie? Access-/Refresh-Token, Rotation, Widerruf — siehe das Glossar.

Vertiefende Ergänzung zum Abschnitt „JWTs ausstellen und erneuern“ des Sicherheitsleitfadens. Für einen einzelnen, manuell verwalteten Token siehe stattdessen JWT (eigenständig).


Inhaltsverzeichnis


Der eingebaute Router

jwt_router hängt die vier Standard-Endpunkte gegen die tenant-spezifische Tabelle rustango_users ein — die ~50 Zeilen Login-Boilerplate, die jedes Projekt sonst neu schreibt:

MethodePfadBody / AuthLiefert
POST/api/auth/login{username, password}{access, refresh, user}
POST/api/auth/refresh{refresh}{access, refresh}
POST/api/auth/logoutAuthorization: Bearer <access> + optional {refresh}204 (widerruft beide JTIs)
GET/api/auth/meAuthorization: Bearer <access>{user_id, username, is_superuser}

Login verifiziert das Passwort mit argon2id und stellt dann ein Paar aus. Pfade, TTLs und der Signierschlüssel sind über Config konfigurierbar.

Die Verdrahtung

use rustango::tenancy::auth_routes::{jwt_router, Config};

rustango::manage::Cli::new()
    .tenancy()
    .api(my_app::urls::api()
        .merge(jwt_router(Config::default())))   // hängt /api/auth/* ein
    .run()
    .await

Config::default() signiert mit RUSTANGO_SESSION_SECRET (demselben Schlüssel wie das Admin-Session-Cookie) und verwendet TTLs von 15 Min. Access / 7 Tage Refresh. Überschreiben Sie prefix, access_ttl_secs, refresh_ttl_secs oder session_secret nach Bedarf. Die Endpunkte laufen im Tenant-Kontext, hängen Sie sie also in einer Tenancy-App ein.

# Login → access + refresh
curl -sX POST localhost:8080/api/auth/login \
  -H 'content-type: application/json' \
  -d '{"username":"alice","password":"hunter2hunter"}'

# Einen geschützten Endpunkt aufrufen
curl localhost:8080/api/auth/me -H "Authorization: Bearer $ACCESS"

Die Token-Engine (JwtLifecycle)

Unter dem Router sitzt JwtLifecycle — direkt nutzbar, wenn Sie den Lebenszyklus ohne die eingebaute HTTP-Form wollen:

use rustango::tenancy::jwt_lifecycle::JwtLifecycle;

let jwt = JwtLifecycle::new(secret_32_bytes);

// Login: das Paar ausstellen.
let pair = jwt.issue_pair(user_id);
// → pair.access  (kurze TTL, im Authorization-Header senden)
// → pair.refresh (lange TTL, in einem HttpOnly-Cookie / sicheren Speicher ablegen)

// Authentifizierte Anfrage: den Access-Token verifizieren.
match jwt.verify_access(&access).await {
    Some(claims) => { /* claims.sub ist die Benutzer-ID */ }
    None => { /* 401: ungültig, abgelaufen, widerrufen oder falscher Typ */ }
}

Access- und Refresh-Tokens sind nicht austauschbar — verify_access lehnt einen Refresh-Token ab und umgekehrt, sodass ein gestohlener kurzlebiger Access-Token nicht zum Erzeugen neuer Tokens verwendet werden kann:

let pair = jwt.issue_pair(42);
assert!(jwt.verify_refresh(&pair.access).await.is_none());
assert!(jwt.verify_access(&pair.refresh).await.is_none());

Refresh und Rotation

refresh tauscht einen gültigen Refresh-Token gegen ein neues Paar und setzt die JTI des alten Refresh-Tokens auf die Sperrliste — gleitender Ablauf mit Refresh-Tokens für den einmaligen Gebrauch (das Wiedereinspielen des alten wird abgelehnt):

let pair = jwt.issue_pair(7);
let rotated = jwt.refresh(&pair.refresh).await.expect("refresh ok");
assert_ne!(pair.access, rotated.access);
assert!(jwt.refresh(&pair.refresh).await.is_none());   // der alte Refresh ist jetzt tot

Standardmäßig bewahrt refresh die benutzerdefinierten Claims des Tokens. Wenn sich Berechtigungen geändert haben könnten (Rolle widerrufen, Scope herabgestuft), verwenden Sie refresh_with(token, new_claims), um ein frisches Payload einzusetzen, während die alte Refresh-JTI dennoch auf die Sperrliste gesetzt wird.


Widerruf und der JTI-Speicher

Jeder Token trägt eine eindeutige jti. revoke fügt sie einer Sperrliste hinzu, sodass nachfolgende verify_*-Aufrufe fehlschlagen, bis der Token ohnehin abgelaufen wäre — genau das ruft POST /api/auth/logout auf:

let pair = jwt.issue_pair(1);
assert!(jwt.revoke(&pair.access).await);
assert!(jwt.verify_access(&pair.access).await.is_none());

Den Refresh-Token an /logout senden

Nur den Bearer zu widerrufen beendet einen Token, der ohnehin in Minuten abgelaufen wäre. Der Refresh-Token ist derjenige mit tagelanger Lebensdauer und kann während seiner gesamten TTL neue Access-Token ausstellen — ein Logout, das ihn am Leben lässt, beendet die Sitzung also nicht, es verschiebt sie nur:

POST /api/auth/logout
Authorization: Bearer <access>
{ "refresh": "<refresh>" }        // widerruft auch die langlebige Hälfte

Der Body ist optional, sodass Clients, die gegen den älteren Endpunkt geschrieben wurden, unverändert weiterlaufen — sie widerrufen einfach weniger. Senden Sie ihn. Beide Hälften sind an den aufrufenden Mandanten gebunden, sodass eine Subdomain den Token einer anderen nicht widerrufen kann.

Die Sperrliste liegt in einem austauschbaren JtiStore. Der Standard InMemoryJtiStore ist einprozessig und verliert Widerrufe beim Neustart — in Ordnung für eine einzelne Instanz. Jede Multi-Replica-Bereitstellung MUSS einen gemeinsamen, dauerhaften Speicher (Redis / DB) installieren, damit ein Logout auf einer Replica von allen respektiert wird:

use rustango::jti_store::{InMemoryJtiStore, JtiStore};
use std::sync::Arc;

let shared: Arc<dyn JtiStore> = Arc::new(InMemoryJtiStore::new()); // in Prod durch Redis ersetzen
let a = JwtLifecycle::new(secret.clone()).with_jti_store(Arc::clone(&shared));
let b = JwtLifecycle::new(secret).with_jti_store(Arc::clone(&shared));

let pair = a.issue_pair(5);
a.revoke(&pair.access).await;
assert!(b.verify_access(&pair.access).await.is_none());   // B sieht As Widerruf

Ohne gemeinsamen Speicher ist /logout bestenfalls „best-effort“: Ein widerrufener Token kann auf einer anderen Replica bis zu seinem natürlichen Ablauf noch akzeptiert werden. Dies ist die einzelne wichtigste Produktionseinstellung für JWT-Auth.

Einen dauerhaften Speicher schreiben

JtiStore ist asynchron (seit v0.52, #1191), eine dauerhafte Implementierung ist also eine einzige Abfrage — kein Hintergrund-Flusher, kein Konvergenzfenster, in dem ein widerrufener jti woanders noch akzeptiert wird. Beide Methoden geben JtiFuture<'_, T> zurück (ein geboxtes Future — der Trait wird als Arc<dyn JtiStore> verwendet, und natives async fn in Traits ist nicht dyn-kompatibel):

use rustango::jti_store::{JtiFuture, JtiStore};

impl JtiStore for PgJtiStore {
    fn is_used<'a>(&'a self, jti: &'a str) -> JtiFuture<'a, bool> {
        Box::pin(async move { self.lookup(jti).await })
    }

    fn mark_used<'a>(&'a self, jti: &'a str, exp_unix: i64) -> JtiFuture<'a, bool> {
        Box::pin(async move { self.insert_if_absent(jti, exp_unix).await })
    }
}

mark_used MUSS atomar sein: Bei gleichzeitigen Aufrufen für denselben jti darf genau einer true sehen — ein einziger bedingter Schreibvorgang (INSERT … ON CONFLICT DO NOTHING, Redis SET NX), niemals Lesen-dann-Schreiben. Sonst ist die Einmalverwendungs-Garantie für Refresh-Tokens verloren.

Da die Verifizierung den Speicher konsultiert, sind verify_access, verify_refresh, refresh, revoke und die MCP-/Tenant-Token-Helfer (mcp::verify_agent_token, tenancy::auth_routes::verify_for_tenant) alle async. Der Ablauf wird vor dem Speicherzugriff geprüft, ein abgelaufener Token kostet also keinen Roundtrip.


Benutzerdefinierte Claims

Betten Sie roles / tenant / scope direkt in den Token ein, sodass die Verifizierung keine DB-Abfrage benötigt. Reservierte Namen (sub, exp, jti, typ) werden abgelehnt:

let custom = serde_json::json!({ "roles": ["admin"], "tenant": "acme" })
    .as_object().unwrap().clone();
let pair = jwt.issue_pair_with(99, custom)?;

let claims = jwt.verify_access(&pair.access).await.unwrap();
let roles: Vec<String> = claims.get_custom("roles").unwrap();   // ["admin"]

Benutzerdefinierte Claims überleben refresh (werden auf das neue Paar übertragen), es sei denn, Sie verwenden refresh_with.


Hinweise und Grenzen

  • Sessions vs. JWT vs. dies: Ein einfaches JWT kann nicht widerrufen werden; eine Session ist widerrufbar, braucht aber eine Speicherabfrage pro Anfrage; JwtLifecycle ist der Mittelweg — zustandslose Verifizierung, plus eine JTI-Sperrliste für die Widerrufe, die Sie tatsächlich brauchen (Logout, Rotation).

  • HTTP-Endpunkte sind tenant-bezogen. jwt_router löst Benutzer über den Tenant-Kontext + rustango_users auf; hängen Sie ihn in einer .tenancy()-App ein. Die Token-Engine (JwtLifecycle) selbst hat diese Anforderung nicht.

  • Kombinieren Sie dies mit dem JwtBackend der Auth-Backend-Kette, um beliebige Routen aus dem Authorization: Bearer-Header zu authentifizieren.

  • HS256-Signierung, 32-Byte-Schlüssel-Untergrenze — derselbe Algorithmus und dieselben Einschränkungen wie beim eigenständigen JWT.

  • Die Tokens sind gewöhnliche JWTs, also kann alles, was ein JWT prüft, auch diese prüfen: jwt.io, die Standardbibliothek deiner Plattform, ein API-Gateway, ein anderer Dienst, dem du ein Token gibst. Drei Segmente, ein JOSE-Header {"alg":"HS256","typ":"JWT"}, signiert über header.payload.

    Bis #1397 waren es zwei Segmente ohne Header, signiert nur über die Payload — lesbar von nichts außer rustango und sogar von rustango::jwt::decode abgelehnt. Wer dafür einen eigenen Verifier geschrieben hat, kann ihn wegwerfen.

    Vor dem Fix ausgestellte Tokens werden beim Verifizieren weiterhin akzeptiert, damit ein Upgrade niemanden ausloggt. Dieser Kompatibilitätspfad entfällt in 0.58, wenn jedes Token der alten Form längst abgelaufen ist.


Siehe auch