09-oauth-sso.md 15612 octets

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

ProviderType de protocoleParticularités
GoogleOIDC (OpenID Connect)PKCE S256 + nonce, discovery automatique via /.well-known/openid-configuration
GitHubOAuth 2.0PKCE S256, email récupéré via endpoint séparé /user/emails, email_verified basé sur verified=true
MicrosoftOIDCPKCE S256, tenant configurable (OAUTH_MICROSOFT_TENANT, défaut common)
DiscordOAuth 2.0PKCE 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 :

VariableDescriptionDéfaut
OAUTH_GOOGLE_ENABLEDActive le provider Googlefalse
OAUTH_GOOGLE_CLIENT_IDClient ID Google—
OAUTH_GOOGLE_CLIENT_SECRETClient Secret Google—
OAUTH_GITHUB_ENABLEDActive le provider GitHubfalse
OAUTH_GITHUB_CLIENT_IDClient ID GitHub—
OAUTH_GITHUB_CLIENT_SECRETClient Secret GitHub—
OAUTH_DISCORD_ENABLEDActive le provider Discordfalse
OAUTH_DISCORD_CLIENT_IDClient ID Discord—
OAUTH_DISCORD_CLIENT_SECRETClient Secret Discord—
OAUTH_MICROSOFT_ENABLEDActive le provider Microsoftfalse
OAUTH_MICROSOFT_CLIENT_IDClient ID Microsoft—
OAUTH_MICROSOFT_CLIENT_SECRETClient Secret Microsoft—
OAUTH_MICROSOFT_TENANTTenant Azure ADcommon

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 :

  1. Lien existant (oauth_accounts match) → retourner l'utilisateur lié, is_new_user = false.
  2. 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).
  3. Aucun compte existant → créer un nouvel utilisateur avec username = email, assigner le rôle user, 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èreOAuth 2.0 / OIDCFédération ActivityPub
Rôle de RustwardenClient d'un IdP tiersPair dans un réseau décentralisé
ProtocoleOAuth 2.0 + optionnel OIDCActivityPub + HTTP Signatures
Autorité centraleOui (Google, GitHub, etc.)Non (réseau pair-à-pair)
Identité résultanteCompte local users avec lien oauth_accountsIdentité distante remote_identities
JWT émiskind: localkind: remote (RFC 0007)
Modulerustwarden-core feature oauthrustwarden-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éristiquePATOAuth token
ÉmetteurRustwardenProvider externe (Google, etc.)
StockageHash SHA-256 uniquementRow oauth_accounts (lien)
ScopesDéfinis par l'application consommatrice (TokenScope trait)Définis par le provider
RévocationPersonalTokenService::revoke()Suppression du lien OAuth
Durée de vieConfigurable, optionnelleDépend du provider
Usage typiqueScripts CI/CD, accès API sans navigateurLogin 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

ErreurCause probableSolution
OAuth provider did not return an email addressLe provider n'a pas inclus l'email dans le token ou le scope manqueVé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 providerLe provider a renvoyé email_verified=falseL'utilisateur doit vérifier son email auprès du provider avant de pouvoir se connecter
invalid_state ou état non trouvé en sessionLa session a expiré entre l'initiation et le callback, ou cookies bloquésVérifier la durée de vie de la session, vérifier que les cookies tiers ne sont pas bloqués
Failed to exchange codeLe code a expiré (codes OAuth valides ~10 minutes) ou la redirect_uri ne correspond pasVé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 providerAjouter l'URL exacte (schéma, domaine, chemin) dans la console Google/GitHub/Microsoft/Discord
ENCRYPTION_KEY is not setLa variable d'environnement manque au démarrageDéfinir ENCRYPTION_KEY avec une clé hex de 64 caractères (32 octets)
Token PAT rejeté avec InvalidTokenToken révoqué, expiré, ou hash ne correspond pasVé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