09 — OAuth, OIDC et SSO
Ce document explique comment Rustwarden agit en client d'un fournisseur d'identité OAuth2/OIDC externe (Google, GitHub, Microsoft, Discord), comment les comptes sont liés, et comment ce mécanisme se distingue des Personal Access Tokens et de la fédération ActivityPub.
Providers supportés
| Provider | Type de protocole | Particularités |
|---|---|---|
| OIDC (OpenID Connect) | PKCE S256 + nonce, discovery automatique via /.well-known/openid-configuration | |
| GitHub | OAuth 2.0 | PKCE S256, email récupéré via endpoint séparé /user/emails, email_verified basé sur verified=true |
| Microsoft | OIDC | PKCE S256, tenant configurable (OAUTH_MICROSOFT_TENANT, défaut common) |
| Discord | OAuth 2.0 | PKCE S256, email_verified basé sur le champ verified de l'API utilisateur |
Activation
La feature Cargo oauth doit être activée. Elle active les dépendances openidconnect et reqwest :
# Cargo.toml consommateur rustwarden-core = { version = "...", features = ["oauth"] }
Chaque provider est activé individuellement via des variables d'environnement :
| Variable | Description | Défaut |
|---|---|---|
OAUTH_GOOGLE_ENABLED | Active le provider Google | false |
OAUTH_GOOGLE_CLIENT_ID | Client ID Google | — |
OAUTH_GOOGLE_CLIENT_SECRET | Client Secret Google | — |
OAUTH_GITHUB_ENABLED | Active le provider GitHub | false |
OAUTH_GITHUB_CLIENT_ID | Client ID GitHub | — |
OAUTH_GITHUB_CLIENT_SECRET | Client Secret GitHub | — |
OAUTH_DISCORD_ENABLED | Active le provider Discord | false |
OAUTH_DISCORD_CLIENT_ID | Client ID Discord | — |
OAUTH_DISCORD_CLIENT_SECRET | Client Secret Discord | — |
OAUTH_MICROSOFT_ENABLED | Active le provider Microsoft | false |
OAUTH_MICROSOFT_CLIENT_ID | Client ID Microsoft | — |
OAUTH_MICROSOFT_CLIENT_SECRET | Client Secret Microsoft | — |
OAUTH_MICROSOFT_TENANT | Tenant Azure AD | common |
L'URL de callback est construite par le handler OAuth à partir de la base URL de l'application. Le pattern est https://<base_url>/api/v1/oauth/<provider>/callback.
Flow OAuth2 Authorization Code + PKCE
sequenceDiagram
participant U as Utilisateur
participant APP as Rustwarden
participant IDP as Provider (ex. Google)
participant DB as Base de données
U->>APP: Clic "Se connecter avec Google"
APP->>APP: Générer state (CSRF), nonce (OIDC), PKCE verifier + challenge (S256)
APP->>APP: Stocker state, nonce, pkce_verifier en session
APP->>U: 302 → https://accounts.google.com/o/oauth2/v2/auth\n?client_id=...&state=...&nonce=...&code_challenge=...&code_challenge_method=S256
U->>IDP: Consentement utilisateur
IDP->>U: 302 → /api/v1/oauth/google/callback?code=...&state=...
U->>APP: GET /api/v1/oauth/google/callback?code=...&state=...
APP->>APP: Récupérer state, nonce, pkce_verifier depuis session
APP->>APP: Vérifier state (ConstantTimeEq)
APP->>IDP: POST token_endpoint { code, redirect_uri, pkce_verifier }
IDP->>APP: { access_token, id_token, refresh_token }
APP->>APP: Valider id_token (signature, nonce, issuer)
APP->>IDP: GET userinfo endpoint (access_token)
IDP->>APP: { sub, email, email_verified, name, picture }
APP->>DB: SELECT oauth_accounts WHERE provider='google' AND provider_user_id=sub
alt Compte lié existant
DB->>APP: oauth_accounts row
APP->>DB: SELECT users WHERE id=oauth_accounts.user_id
else Aucun lien — email déjà enregistré
APP->>DB: SELECT users WHERE email=email (si email_verified)
APP->>DB: INSERT oauth_accounts (lier le compte existant)
else Nouvel utilisateur
APP->>DB: INSERT users (username=email, email, email_verified=true)
APP->>DB: INSERT user_roles (role='user')
APP->>DB: INSERT oauth_accounts
end
APP->>APP: Émettre hook on_oauth_login(user)
APP->>APP: JwtService::generate_token(user_id, username, roles, permissions)
APP->>U: 200 OK + JWT (header Authorization ou cookie HttpOnly)
Référence implémentation : services/oauth/service.rs, services/oauth/provider_trait.rs
State et PKCE
flowchart LR
subgraph Génération de la requête
S1[CsrfToken::new_random] --> STATE[state\nstocké en session]
S2[Nonce::new_random] --> NONCE[nonce\nstocké en session]
S3[PkceCodeChallenge::new_random_sha256] --> CHALLENGE[code_challenge S256\nenvoyé au provider]
S3 --> VERIFIER[code_verifier\nstocké en session]
end
subgraph Validation du callback
CB[callback reçu\ncode + state] --> VS[Comparer state\nConstantTimeEq]
VS -->|OK| EX[Échanger code\n+ code_verifier]
EX --> VN[Valider nonce\ndans id_token]
VN -->|OK| UI[Appeler userinfo]
end
Pourquoi le state protège-t-il contre le CSRF ? Sans state, un attaquant pourrait initier un flow OAuth depuis son propre compte et faire en sorte que la victime consomme le callback avec le code de l'attaquant, liant le compte de l'attaquant à la session de la victime (attaque de liaison de compte). Le state est lié à la session du navigateur qui a initié la requête.
Pourquoi le PKCE ? Pour les providers qui ne supportent pas les clients confidentiels (GitHub notamment), PKCE garantit que même si le code d'autorisation est intercepté, il est inutilisable sans le code_verifier qui n'a jamais quitté le serveur.
Chiffrement des tokens OAuth en base
Les tokens de rafraîchissement OAuth reçus d'un provider et stockés en base (si le provider les renvoie) sont chiffrés avec CryptoService::encrypt() (AES-256-GCM) avant insertion. Le format stocké est enc:<base64(nonce || ciphertext)>.
La clé de chiffrement est lue depuis ENCRYPTION_KEY ou OAUTH_ENCRYPTION_KEY.
Liens compte multi-provider
Un utilisateur peut lier plusieurs providers OAuth à son compte. La table oauth_accounts stocke un enregistrement par lien :
users (1) ──── (N) oauth_accounts ├── provider='google', provider_user_id='10762...' ├── provider='github', provider_user_id='42' └── provider='discord', provider_user_id='8230...'
L'index UNIQUE (provider, provider_user_id) garantit qu'un compte provider donné ne peut être lié qu'à un seul utilisateur Rustwarden.
Pour délier un compte, l'utilisateur utilise l'endpoint de gestion de profil. La protection métier : on ne peut pas délier le dernier moyen d'authentification si l'utilisateur n'a pas de mot de passe local (OAuthService::unlink_account_by_id vérifie has_password || linked_accounts.len() > 1).
Comportement au premier login (first-login)
Lors du premier login OAuth, le flow OAuthService::find_or_create_user suit cette logique :
- Lien existant (
oauth_accountsmatch) → retourner l'utilisateur lié,is_new_user = false. - Email déjà enregistré (compte local avec le même email) → lier le compte OAuth à l'utilisateur existant,
is_new_user = false. Prérequis : le provider doit avoir renvoyéemail_verified = true. Une adresse non vérifiée par le provider est rejetée (AppError::Validation). - Aucun compte existant → créer un nouvel utilisateur avec
username = email, assigner le rôleuser, créer le lien OAuth,is_new_user = true.
Il n'y a pas de flag de configuration pour désactiver la création automatique d'utilisateur en V1. Un consommateur qui souhaite restreindre les inscriptions OAuth peut implémenter le hook on_oauth_login et rejeter les utilisateurs qui ne sont pas dans une liste d'autorisation.
Diagramme d'état du lien OAuth
stateDiagram-v2
[*] --> Linked : OAuthService::link_account()\nou find_or_create_user()
Linked --> Unlinked : OAuthService::unlink_account()\n(si pas dernier moyen d'auth)
Unlinked --> [*]
note right of Linked
La row oauth_accounts existe
Le provider_user_id est lié à un user_id local
end note
note right of Unlinked
DELETE oauth_accounts WHERE id = ...
La row n'existe plus en base
end note
Il n'existe pas d'état "suspendu" ou "révoqué" pour un lien OAuth en V1 : un lien est soit actif (row présente), soit absent (supprimé). La révocation de l'accès au niveau du provider (ex. l'utilisateur retire l'accès depuis son compte Google) ne met pas à jour la base Rustwarden — le prochain callback échouera simplement à récupérer les tokens.
Différence OAuth/OIDC vs Fédération ActivityPub
Ces deux systèmes sont souvent confondus. Ils répondent à des besoins fondamentalement différents.
flowchart LR
subgraph OAuth_OIDC ["OAuth 2.0 / OIDC (module oauth)"]
direction TB
USER1[Utilisateur] -->|"Login via Google"| RW1[Rustwarden\nclient OAuth]
RW1 -->|"Échange code → tokens"| GOOGLE[Google\nIdP centralisé]
GOOGLE -->|"Infos utilisateur"| RW1
RW1 -->|"Crée compte local\nou lie compte"| DB1[(oauth_accounts)]
end
subgraph AP ["Fédération ActivityPub (rustwarden-fediverse)"]
direction TB
USER2[Acteur @alice@autre.instance] -->|"HTTP Signature"| RW2[Rustwarden\npair AP]
RW2 -->|"Fetch actor JSON"| REMOTE[autre.instance\npair AP]
REMOTE -->|"Clé publique RSA"| RW2
RW2 -->|"Cache identité distante"| DB2[(remote_identities)]
end
style OAuth_OIDC fill:#e8f4fd,stroke:#4a90d9
style AP fill:#f0ebfa,stroke:#7c5cbf
| Critère | OAuth 2.0 / OIDC | Fédération ActivityPub |
|---|---|---|
| Rôle de Rustwarden | Client d'un IdP tiers | Pair dans un réseau décentralisé |
| Protocole | OAuth 2.0 + optionnel OIDC | ActivityPub + HTTP Signatures |
| Autorité centrale | Oui (Google, GitHub, etc.) | Non (réseau pair-à-pair) |
| Identité résultante | Compte local users avec lien oauth_accounts | Identité distante remote_identities |
| JWT émis | kind: local | kind: remote (RFC 0007) |
| Module | rustwarden-core feature oauth | rustwarden-fediverse |
Pour la fédération ActivityPub, voir 08-fediverse-integration.md.
Sécurité OAuth
Rotation des secrets : les CLIENT_SECRET ne sont jamais stockés en base. Ils sont lus depuis les variables d'environnement au démarrage. La rotation d'un secret implique uniquement la mise à jour de la variable et le redémarrage de l'application.
Scopes minimaux : chaque provider demande uniquement les scopes nécessaires à l'identification :
- Google :
openid email profile - GitHub :
read:user user:email - Microsoft :
openid email profile - Discord :
identify email
Validation de l'issuer : pour les providers OIDC (Google, Microsoft), l'issuer du id_token est validé contre la valeur attendue issue du discovery document. Un token émis par un autre issuer est rejeté.
Expiration des tokens : les access_token reçus du provider ne sont pas mis en cache — ils sont utilisés immédiatement pour appeler userinfo puis abandonnés. Il n'y a pas de logique de rafraîchissement de token provider en V1.
Personal Access Tokens (PAT)
Les PAT sont distincts d'OAuth. Ce sont des tokens locaux à Rustwarden, gérés directement par l'utilisateur, sans impliquer un provider externe.
| Caractéristique | PAT | OAuth token |
|---|---|---|
| Émetteur | Rustwarden | Provider externe (Google, etc.) |
| Stockage | Hash SHA-256 uniquement | Row oauth_accounts (lien) |
| Scopes | Définis par l'application consommatrice (TokenScope trait) | Définis par le provider |
| Révocation | PersonalTokenService::revoke() | Suppression du lien OAuth |
| Durée de vie | Configurable, optionnelle | Dépend du provider |
| Usage typique | Scripts CI/CD, accès API sans navigateur | Login utilisateur via navigateur |
Génération : 32 octets aléatoires générés avec rand::rng().fill_bytes(), encodés en hex (64 caractères). Le plaintext est retourné une seule fois à la création.
Stockage : seul le digest SHA-256 (hex, 64 caractères) est persisté dans la colonne token_hash. Le plaintext n'est jamais journalisé.
Vérification : PersonalTokenService::verify() hache le token présenté, cherche le digest en base, puis effectue une comparaison constant-time (subtle::ConstantTimeEq) sur le hash pour se protéger contre les timing oracles.
Scopes : le trait TokenScope est générique — chaque application consommatrice définit son propre vocabulaire de scopes. Les scopes sont stockés comme un tableau text[] en PostgreSQL.
// Exemple de définition de scopes dans une application consommatrice #[derive(Clone, Debug)] enum MyScope { ReadRepo, WriteRepo } impl TokenScope for MyScope { fn as_str(&self) -> &str { match self { MyScope::ReadRepo => "read:repo", MyScope::WriteRepo => "write:repo" } } fn parse(s: &str) -> Option<Self> { match s { "read:repo" => Some(Self::ReadRepo), _ => None } } } // Création d'un PAT let (record, plaintext) = PersonalTokenService::<MyScope>::create( &db, user_id, "ci-bot", &[MyScope::ReadRepo], None ).await?; // plaintext à afficher une seule fois à l'utilisateur
Référence : services/personal_token_service.rs
Troubleshooting
| Erreur | Cause probable | Solution |
|---|---|---|
OAuth provider did not return an email address | Le provider n'a pas inclus l'email dans le token ou le scope manque | Vérifier que les scopes incluent email ; pour GitHub, vérifier que l'email est public ou que le scope user:email est demandé |
Email address is not verified by the OAuth provider | Le provider a renvoyé email_verified=false | L'utilisateur doit vérifier son email auprès du provider avant de pouvoir se connecter |
invalid_state ou état non trouvé en session | La session a expiré entre l'initiation et le callback, ou cookies bloqués | Vérifier la durée de vie de la session, vérifier que les cookies tiers ne sont pas bloqués |
Failed to exchange code | Le code a expiré (codes OAuth valides ~10 minutes) ou la redirect_uri ne correspond pas | Vérifier que la redirect_uri dans les credentials du provider correspond exactement à l'URL du callback Rustwarden |
redirect_uri_mismatch (erreur provider) | L'URL de callback Rustwarden n'est pas enregistrée dans la console du provider | Ajouter l'URL exacte (schéma, domaine, chemin) dans la console Google/GitHub/Microsoft/Discord |
ENCRYPTION_KEY is not set | La variable d'environnement manque au démarrage | Définir ENCRYPTION_KEY avec une clé hex de 64 caractères (32 octets) |
Token PAT rejeté avec InvalidToken | Token révoqué, expiré, ou hash ne correspond pas | Vérifier revoked_at et expires_at dans personal_access_tokens ; régénérer le token si nécessaire |
Lien OAuth impossible (Cannot unlink the last authentication method) | L'utilisateur n'a ni mot de passe local ni autre provider lié | L'utilisateur doit définir un mot de passe local avant de délier le dernier provider OAuth |
GitRust