---
title: "Authentification"
description: "Crée un compte utilisateur. Déclenche l'envoi d'un email contenant un lien de confirmation. Le compte est créé en base avec status = 1, user_type = 1, conf…"
---

## `POST /api/auth/register`

Crée un compte utilisateur. Déclenche l'envoi d'un email contenant un lien de confirmation. Le compte est créé en base avec `status = 1`, `user_type = 1`, `confirmed_at = NULL` — l'utilisateur **ne peut pas se connecter tant qu'il n'a pas confirmé son email** (`POST /api/auth/confirm-email`).

- **Auth** : aucune
- **Action** : [RegisterAction](../src/Http/Action/Api/Auth/RegisterAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "username":        "havoc",
  "email":           "user@example.com",
  "password":        "MySuperPassw0rd!",
  "sponsorshipCode": "A3K7QM",
  "couponId":        "WELCOME2026"
}
```

`sponsorshipCode` est **optionnel** (omission, `null` ou chaîne vide = pas de
parrain). Si fourni, il doit matcher un code existant de la table
`sponsorship` — sinon `422` (cf. codes d'erreur). Voir
[section Parrainage](#parrainage) pour le détail du format.

`couponId` est **optionnel** et **non bloquant** : un coupon inconnu, expiré
ou déjà épuisé ne fait JAMAIS échouer l'inscription. Le résultat de la
tentative est exposé dans `meta.coupon` sur la réponse `201`. Voir
[section Coupons](#coupons) pour le détail.

### Politiques de validation

**Username** ([UsernamePolicy](../src/Domain/User/UsernamePolicy.php)) :
- 3 à 32 caractères
- ASCII : `[a-z0-9._-]` uniquement (forcé en minuscules à la persistance)
- Doit commencer et finir par un caractère alphanumérique
- Pas de séparateurs consécutifs (`..`, `__`, `-_`, …)
- Unicité case-insensitive

**Password** ([PasswordPolicy](../src/Domain/Auth/PasswordPolicy.php)) :
- 12 à 72 octets (72 = limite bcrypt)
- Au moins : 1 minuscule, 1 majuscule, 1 chiffre, 1 caractère non alphanumérique
- Pas de NUL byte, pas d'espace en début/fin
- Ne doit pas contenir le username ni la partie locale de l'email

**Email** : `filter_var(..., FILTER_VALIDATE_EMAIL)` + max 255 caractères + unicité.

- **Réponse `201`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":    "havoc",
      "email":       "user@example.com",
      "userType":    1,
      "status":      1,
      "joinedAt":    "2026-06-06T12:34:56+00:00",
      "isConfirmed": false
    }
  },
  "meta": {
    "confirmationEmailSent": true,
    "coupon": {
      "applied":  true,
      "couponId": "WELCOME2026",
      "rewards":  [{ "type": "experience", "value": 100 }]
    }
  }
}
```

`meta.coupon` n'est présent que si le client a fourni `couponId`. Sur échec :

```json
"coupon": {
  "applied":  false,
  "couponId": "WELCOME2026",
  "reason":   "coupon.expired",
  "message":  "Ce coupon a expiré."
}
```

- **Réponse `400`** — corps non-JSON / non-objet
- **Réponse `422`** — un ou plusieurs `errors[]` avec :
  - `source.pointer = /data/attributes/<field>`
  - `meta.code` typé pour i18n côté frontend
  - Codes possibles :
    - `username.tooShort`, `username.tooLong`, `username.invalidCharacters`, `username.invalidBoundary`, `username.consecutiveSeparators`, `username.reserved`, `username.alreadyTaken`
    - `email.invalidFormat`, `email.alreadyTaken`
    - `password.tooShort`, `password.tooLong`, `password.missingLowercase`, `password.missingUppercase`, `password.missingDigit`, `password.missingSpecial`, `password.invalidCharacters`, `password.invalidBoundary`, `password.containsUsername`, `password.containsEmail`
    - `sponsorship.codeInvalid` (forme du code incorrecte : longueur, caractères hors alphabet), `sponsorship.codeNotFound` (code bien formé mais inconnu de la table `sponsorship`)

### Usernames réservés

Le code `username.reserved` est renvoyé quand le username demandé figure dans la blocklist statique [`config/username_blocklist.php`](../config/username_blocklist.php) (noms système/rôles, identité de marque, routes techniques), chargée dans [UsernameBlocklist](../src/Domain/User/UsernameBlocklist.php). La vérification s'applique partout où [UsernamePolicy](../src/Domain/User/UsernamePolicy.php) est utilisée (inscription, complétion de profil, changement de username). Le match porte sur la forme normalisée **et** sur une forme sans séparateurs (`.`/`_`/`-`), de sorte que `admin` bloque aussi `a.d.m.i.n`. La liste évolue par PR ; aucune table DB en V1.

### Note sur l'énumération

Les erreurs `username.alreadyTaken` et `email.alreadyTaken` sont explicites — un attaquant peut donc tester l'existence d'un email. Choix assumé : les usernames sont publics dans une app sociale, et un endpoint d'« availability check » sera de toute façon nécessaire pour l'UX du formulaire. À durcir si le produit devient sensible.

### Mailer en dev

Tant qu'un vrai mailer n'est pas branché, l'email est écrit dans `var/logs/mail.log` ([LogMailer](../src/Infrastructure/Mail/LogMailer.php)). Pour récupérer le token de confirmation pendant les tests :

```bash
tail -n 30 var/logs/mail.log
```

---

## `POST /api/auth/confirm-email`

Consomme le token reçu par email et marque l'utilisateur comme confirmé (`user.confirmed_at = NOW()`). Le token est à usage unique et expire après 24h.

- **Auth** : aucune
- **Action** : [ConfirmEmailAction](../src/Http/Action/Api/Auth/ConfirmEmailAction.php)
- **Request body** :

```json
{ "token": "<base64url-token>" }
```

(également accepté en forme JSON:API : `data.attributes.token`)

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":    "havoc",
      "email":       "user@example.com",
      "isConfirmed": true,
      "confirmedAt": "2026-06-06T13:00:00+00:00"
    }
  }
}
```

- **Réponse `400`** — token inconnu, expiré, ou déjà utilisé (même message pour les trois cas afin d'éviter un oracle)
- **Réponse `422`** — attribut `token` manquant

- **Notes** :
  - Une nouvelle demande de confirmation pour le même user invalide automatiquement tous les tokens précédents non utilisés (`invalidatePendingForUser`).
  - Le token brut n'est jamais stocké : seul son SHA-256 (32 octets) est en base ([table `email_confirmation`](../database/migrations/2026_06_06_140000_create_email_confirmation_table.sql)).

---

## `POST /api/auth/confirm-email-change`

Consomme un token de **changement d'adresse e-mail** amorcé par [`PATCH /api/users/me/profile`](/api/profil-utilisateur/#patch-apiusersmeprofile) (clé `email`). Le token, envoyé à la **nouvelle** adresse, prouve à lui seul la possession de la boîte — l'endpoint est donc **public** (le swap fonctionne même hors session). Token à usage unique, expire après **24h**.

- **Auth** : aucune.
- **Action** : [ConfirmEmailChangeAction](../src/Http/Action/Api/Users/ConfirmEmailChangeAction.php).
- **Corps** : `{ "token": "<base64url-token>" }` (aussi accepté en `data.attributes.token`).

- **Réponses** :
  - `200` — la ressource `users` portant la **nouvelle** `email` (mêmes attributs que `GET /api/auth/me`).
  - `400` — token inconnu, expiré, ou déjà utilisé (même message pour les trois cas, anti-oracle).
  - `409` — l'adresse cible a été réservée par un autre compte entre la demande et la confirmation (`Email already in use`).
  - `422` — attribut `token` manquant ou non-string.

- **Notes** :
  - L'ancienne adresse reste active tant que le token n'est pas confirmé ; `confirmed_at` reste inchangé (distinct de `/auth/confirm-email` qui, lui, l'écrit).
  - Une nouvelle demande invalide les tokens précédents non utilisés.
  - Le token brut n'est jamais stocké (seul son SHA-256 est en base, table `email_change_request`).
  - `email` étant indexé Meilisearch, l'index est resynchronisé au swap (best-effort).
  - Landing web : `GET /confirm-email-change?token=…` (page non indexée).

---

## `POST /api/auth/resend-confirmation`

Renvoie un email de confirmation pour un compte **existant et non encore confirmé**.

- **Auth** : **publique** (l'utilisateur n'a pas pu se connecter puisqu'il n'est pas confirmé)
- **Action** : [ResendConfirmationAction](../src/Http/Action/Api/Auth/ResendConfirmationAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "email": "user@example.com" }
```

### Comportement anti-énumération

L'endpoint **renvoie toujours la même réponse `202 Accepted`**, peu importe que :

- l'email soit inconnu ;
- le compte soit déjà confirmé (`confirmed_at IS NOT NULL`) ;
- une demande précédente ait été faite il y a moins de `cooldownSeconds` (cooldown actif) ;
- l'envoi de l'email ait réellement eu lieu.

Cela empêche un attaquant d'inférer l'existence d'un compte (ou son état) à partir du status code ou du corps de la réponse.

### Rate-limit ([ResendConfirmationService](../src/Domain/Auth/ResendConfirmationService.php))

- **Cooldown 60 s** par compte. Calcul : `MAX(email_confirmation.created_at) WHERE user_id = X`. Si la dernière demande date d'il y a < 60 s, l'envoi est silencieusement supprimé (toujours `202`).
- Si l'envoi a lieu, **toutes les confirmations pending du user sont invalidées** (`used_at = NOW()`) avant l'émission du nouveau token — un seul token actif à la fois (logique de [`EmailConfirmationService::issueFor()`](../src/Domain/Auth/EmailConfirmationService.php)).

- **Réponse `202`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "confirmationResends",
    "id":   "pending",
    "attributes": {
      "message": "If this email is registered and not yet confirmed, a new confirmation link has been sent."
    }
  },
  "meta": { "cooldownSeconds": 60 }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `422`** — `email` manquant ou vide

- **Notes** :
  - Le token est généré comme à l'inscription (32 octets CSPRNG, base64url, hash SHA-256 stocké) et expire après `EmailConfirmationService::LIFETIME_HOURS` (24h).
  - L'email est envoyé via [`ConfirmationEmailSender`](../src/Domain/Auth/ConfirmationEmailSender.php) — même template que l'inscription pour cohérence.
  - L'ID retourné est volontairement la chaîne `"pending"` (et non un UUID) : ne pas exposer d'identifiant de ressource créée afin de ne pas confirmer/infirmer l'existence du compte.

---

## `POST /api/auth/forgot-password`

Émet un email de réinitialisation de mot de passe pour un compte existant.

- **Auth** : **publique**
- **Action** : [ForgotPasswordAction](../src/Http/Action/Api/Auth/ForgotPasswordAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "email": "user@example.com" }
```

### Comportement anti-énumération

L'endpoint **renvoie toujours `202 Accepted`** avec le même corps, peu importe que :

- l'email soit inconnu ;
- le compte soit OAuth-only (sans mot de passe réel — autorisé : permet d'ajouter un mot de passe) ;
- le compte ne soit pas encore confirmé (autorisé : la consommation du token confirmera l'email implicitement) ;
- une demande précédente ait été faite il y a moins de `cooldownSeconds` (cooldown actif) ;
- l'envoi de l'email ait réellement eu lieu.

### Rate-limit ([PasswordResetRequestService](../src/Domain/Auth/PasswordResetRequestService.php))

- **Cooldown 60 s** par compte. Calcul : `MAX(password_reset.created_at) WHERE user_id = X`. Si la dernière demande date d'il y a < 60 s, l'envoi est silencieusement supprimé.
- À chaque envoi réussi, **toutes les demandes pending du user sont invalidées** (`used_at = NOW()`) — un seul token actif à la fois.

- **Réponse `202`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "passwordResetRequests",
    "id":   "pending",
    "attributes": {
      "message": "If this email is registered, a password-reset link has been sent."
    }
  },
  "meta": { "cooldownSeconds": 60 }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `422`** — `email` manquant ou vide

- **Notes** :
  - Le token est 32 octets CSPRNG, base64url, hash SHA-256 stocké dans `password_reset.token_hash` (BINARY(32) UNIQUE).
  - Durée de vie : `PasswordResetService::LIFETIME_HOURS = 1` heure.
  - L'email est envoyé via [`PasswordResetEmailSender`](../src/Domain/Auth/PasswordResetEmailSender.php) qui rend le template Twig [`password_reset.twig`](../templates/mails/password_reset.twig). Le lien pointe vers `APP_URL + /reset-password?token=...`.

---

## `POST /api/auth/reset-password`

Consomme un token de réinitialisation et applique un nouveau mot de passe.

- **Auth** : **publique** (le user est précisément en train de récupérer son accès)
- **Action** : [ResetPasswordAction](../src/Http/Action/Api/Auth/ResetPasswordAction.php)
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{
  "token":       "<plain token reçu par email>",
  "newPassword": "MyNewPassw0rd!"
}
```

### Effets de bord

Sur succès, [`PasswordResetService::consume()`](../src/Domain/Auth/PasswordResetService.php) effectue dans l'ordre :

1. **Remplace** le mot de passe (bcrypt cost 12, met à jour `password_set_at`).
2. **Confirme l'email** si le compte ne l'était pas (`confirmed_at = NOW()`) — la possession du token prouve le contrôle de la boîte. `meta.emailConfirmedByReset = true` dans ce cas.
3. **Révoque toutes les sessions** du user (`DELETE FROM user_session WHERE user_id = X`). L'utilisateur devra se reconnecter partout. `meta.sessionsRevoked` donne le compte.
4. **Marque le token consommé** (`used_at = NOW()`) — non rejouable.

- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "passwordResets",
    "id":   "<user-uuid>",
    "attributes": {
      "changedAt": "2026-06-07T11:00:00+00:00"
    }
  },
  "meta": {
    "sessionsRevoked":       2,
    "emailConfirmedByReset": false
  }
}
```

- **Réponse `400`** — corps non-JSON
- **Réponse `401`** — token rejeté. `meta.code` :
  - `passwordReset.tokenInvalid` — inconnu
  - `passwordReset.tokenExpired` — au-delà de 1h
  - `passwordReset.tokenUsed` — déjà consommé
- **Réponse `422`** — `token` ou `newPassword` manquant, ou échec de la [PasswordPolicy](../src/Domain/Auth/PasswordPolicy.php). Codes : `password.tooShort`, `password.tooLong`, `password.missingLowercase`, `password.missingUppercase`, `password.missingDigit`, `password.missingSpecial`, `password.invalidCharacters`, `password.invalidBoundary`, `password.containsUsername`, `password.containsEmail`

- **Notes** :
  - Aucun token de session n'est émis — l'utilisateur doit se reconnecter via `/auth/login`. C'est volontaire (le reset peut résulter d'un compromis, on force une auth complète).
  - Pour un compte OAuth-only, le reset **crée** son premier mot de passe — il pourra ensuite se connecter par mot de passe **ou** par Google (au choix).

---

## `POST /api/auth/oauth/google`

Échange un **Google ID Token** (obtenu côté frontend après le sign-in Google) contre un bearer token de session Hydrogen. Selon l'état du compte, l'endpoint peut **connecter** un utilisateur existant, **lier** une identité Google à un compte existant, ou **créer** un nouveau compte.

- **Auth** : aucune
- **Action** : [GoogleLoginAction](../src/Http/Action/Api/Auth/OAuth/GoogleLoginAction.php)
- **Pré-requis serveur** : variable d'env `GOOGLE_OAUTH_CLIENT_ID` (Client ID OAuth récupéré dans la [Google Cloud Console](https://console.cloud.google.com/apis/credentials)).
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<google id_token>" }
```

### Vérification du token

[GoogleIdTokenVerifier](../src/Domain/Auth/OAuth/GoogleIdTokenVerifier.php) :
1. Récupère les JWKS Google (`https://www.googleapis.com/oauth2/v3/certs`), avec cache disque TTL **1h** sous `var/cache/oauth/google_jwks.json`.
2. Vérifie la signature RS256, `iss` (`accounts.google.com` ou `https://accounts.google.com`), `aud` (= `GOOGLE_OAUTH_CLIENT_ID`), `exp` (avec 60s de skew).
3. Extrait `sub`, `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `locale`.

### Logique de résolution ([OAuthLoginService](../src/Domain/Auth/OAuth/OAuthLoginService.php))

| Cas | Condition | Résultat | Code HTTP | `meta.oauthOutcome` |
| --- | --------- | -------- | --------- | ------------------- |
| Identité Google déjà liée | (provider, sub) existe en `user_oauth_identity` | sign-in du user lié | `200` | `existingIdentity` |
| Auto-link (C3) | email Google = email d'un user existant, **et** ce user a `confirmed_at IS NOT NULL`, **et** `email_verified = true` côté Google | lien créé puis sign-in | `200` | `linkedToExistingUser` |
| Auto-link refusé | email match un user, mais une des deux conditions C3 manque | `409 Conflict`, l'utilisateur doit se connecter par mot de passe et lier Google manuellement plus tard | `409` | — |
| Nouveau compte | aucune correspondance | création d'un user avec **username placeholder** (`g_XXXXXXXX`, 4 octets aléatoires en hex), `confirmed_at` = NOW(), mot de passe inutilisable (bcrypt aléatoire), `profile_completed_at = NULL` | `201` | `registered` |
| Banni | user trouvé mais `bannedUntil` futur | refus | `403` | — |

- **Réponse `200` / `201`** — même forme que `POST /api/auth/login` plus :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username":           "g_3f2a91b0",
      "email":              "user@gmail.com",
      "confirmedAt":        "2026-06-06T12:34:56+00:00",
      "profileCompletedAt": null,
      "isConfirmed":        true,
      "isBanned":           false
      /* + tous les autres champs habituels */
    }
  },
  "meta": {
    "token":           "<base64url-token>",
    "sessionId":       "<session-uuid>",
    "expiresAt":       "2026-07-06T12:34:56+00:00",
    "oauthOutcome":    "registered",
    "profileComplete": false
  }
}
```

- **Réponse `401`** — `Invalid Google ID token` (signature invalide, expiré, mauvais `iss`/`aud`, JWKS injoignable, etc. — message volontairement non détaillé)
- **Réponse `403`** — `Account banned`, `meta.bannedUntil`
- **Réponse `409`** — `Account exists with this email`, `meta.code = "oauth.linkRefused"` (un compte existe mais l'auto-link est refusé)
- **Réponse `422`** — `idToken` manquant ou vide

### Notes

- Les comptes créés via Google ont un **username placeholder** (`g_<8-hex>`) et `profile_completed_at = NULL`. Un endpoint dédié (à venir) permettra à l'utilisateur de choisir son vrai username.
- Le mot de passe stocké est un bcrypt d'octets aléatoires CSPRNG — `password_verify` échoue toujours, donc impossible de se connecter via `/api/auth/login` tant que l'utilisateur n'a pas explicitement défini un mot de passe.
- La table `user_oauth_identity` ([migration](../database/migrations/2026_06_06_150000_create_user_oauth_identity_table.sql)) stocke `(provider, provider_user_id)` UNIQUE — un même compte Google ne peut être lié qu'à un seul user Hydrogen.

### Matrice des providers OAuth

Récapitulatif des différences entre les trois providers supportés. Chaque cellule reflète une décision du framework Hydrogen, pas seulement une capacité du provider.

| Capacité                                                  | Google                          | Apple                                         | Facebook                                              |
| --------------------------------------------------------- | ------------------------------- | --------------------------------------------- | ----------------------------------------------------- |
| Type de jeton accepté                                     | OIDC ID Token (JWT)             | OIDC ID Token (JWT)                           | Access token opaque **OU** OIDC ID Token (JWT)        |
| Flux côté frontend                                        | un seul (`idToken`)             | un seul (`idToken`)                           | deux : `classic` (web/Android) ou `limited` (iOS ≥13) |
| `email` fourni                                            | toujours                        | toujours                                      | **optionnel** (l'utilisateur peut refuser le scope)   |
| `email_verified` signal côté provider                     | oui (booléen)                   | oui (`"true"`/`"false"` ou booléen, coercé)   | **non**                                               |
| Auto-link sur email existant (C3, sign-in)                | **oui** si confirmé + verified  | **non, jamais** (E.a strict)                  | **non, jamais** (E.a strict)                          |
| Lien manuel depuis le compte connecté                     | oui                             | oui                                           | oui                                                   |
| `email_is_relay` possible                                 | non                             | **oui** (`@privaterelay.appleid.com`)         | non                                                   |
| Prefix du username placeholder à la création              | `g_<8-hex>`                     | `a_<8-hex>`                                   | `f_<8-hex>`                                           |
| Variables d'env requises                                  | `GOOGLE_OAUTH_CLIENT_ID`        | `APPLE_OAUTH_CLIENT_IDS` (liste)              | `FACEBOOK_APP_ID`, `FACEBOOK_APP_SECRET`              |
| JWKS                                                      | `googleapis.com/oauth2/v3/certs` | `appleid.apple.com/auth/keys`                | `facebook.com/.well-known/oauth/openid/jwks/`         |

**Politique E.a (strict)** — Apple et Facebook **ne sont jamais auto-liés** à un compte Hydrogen existant qui aurait le même email. Raison : Apple Private Relay réduit l'assurance d'identité (l'utilisateur peut relayer un email vers n'importe quelle boîte) et Facebook ne renvoie aucun `email_verified`. Si un compte existe déjà avec cet email, la réponse est `409 oauth.linkRefused` — le client doit demander une connexion par mot de passe puis appeler `POST /api/users/me/oauth/<provider>` depuis l'espace authentifié.

---

## `POST /api/auth/oauth/apple`

Échange un **Apple ID Token** (issu de Sign in with Apple) contre un bearer token de session Hydrogen.

- **Auth** : aucune
- **Action** : [AppleLoginAction](../src/Http/Action/Api/Auth/OAuth/AppleLoginAction.php)
- **Pré-requis serveur** : variable d'env `APPLE_OAUTH_CLIENT_IDS` (liste **séparée par des virgules** des Services ID / Bundle ID acceptés ; Apple permet à un seul Developer Team de signer pour plusieurs `aud`, tous valides pour le même `sub`).
- **Request body** (forme plate ou JSON:API `data.attributes`) :

```json
{ "idToken": "<apple id_token>" }
```

### Vérification du token

[AppleIdTokenVerifier](../src/Domain/Auth/OAuth/AppleIdTokenVerifier.php) :
1. Récupère les JWKS Apple (`https://appleid.apple.com/auth/keys`), cache disque TTL **1h** sous `var/cache/oauth/apple_jwks.json`.
2. Vérifie la signature RS256, `iss = https://appleid.apple.com`, `aud ∈ APPLE_OAUTH_CLIENT_IDS`, `exp` (skew 60s).
3. Extrait `sub`, `email`, `email_verified` (coercé en booléen — Apple envoie parfois `"true"`/`"false"` sous forme de string).

### Logique de résolution

Identique à Google **sauf** que l'**auto-link est désactivé (E.a strict)** : un user Hydrogen existant avec le même email reçoit toujours `409 oauth.linkRefused`, jamais `linkedToExistingUser`.

| Cas | Résultat | Code | `meta.oauthOutcome` |
| --- | -------- | ---- | ------------------- |
| Identité Apple déjà liée | sign-in du user lié | `200` | `existingIdentity` |
| Email match user existant | refus auto-link (E.a) | `409 oauth.linkRefused` | — |
| Nouveau compte | création (`username = a_XXXXXXXX`, `confirmed_at` = NOW(), mot de passe inutilisable) | `201` | `registered` |
| Banni | refus | `403` | — |

- **Réponse `200` / `201`** — même forme que Google plus `meta.emailIsRelay: true|false` (drapeau Private Relay).
- **Réponse `401`** — id_token non vérifiable
- **Réponse `403`** — `Account banned`
- **Réponse `409`** — `oauth.linkRefused`
- **Réponse `422`** — `idToken` manquant

### Apple Private Relay

Quand l'utilisateur choisit « Hide My Email » au moment du consentement, Apple émet un alias `<random>@privaterelay.appleid.com`. Cet alias est traité comme un email normal côté Hydrogen (envois fonctionnent, MX d'Apple), mais :
- la colonne `user_oauth_identity.email_is_relay = 1` est positionnée,
- la meta de la réponse expose `emailIsRelay: true`,
- l'application cliente doit informer l'utilisateur que désactiver le forwarding lui ferait perdre l'accès au compte (clef de traduction `auth.oauth.apple.relayNotice`).

---

## `POST /api/auth/oauth/facebook`

Échange un jeton Facebook contre un bearer token de session Hydrogen. **Deux flux** sont supportés, sélectionnés par le champ `flow` du body.

- **Auth** : aucune
- **Action** : [FacebookLoginAction](../src/Http/Action/Api/Auth/OAuth/FacebookLoginAction.php)
- **Pré-requis serveur** : `FACEBOOK_APP_ID` et `FACEBOOK_APP_SECRET` (le secret reste sur le serveur, utilisé pour construire l'`app_token = <id>|<secret>` consommé par `debug_token`).
- **Request body** :

```json
// Flux classique (web SDK, Android)
{ "flow": "classic", "accessToken": "<facebook access token>" }

// Flux limited (iOS ≥ 13)
{ "flow": "limited", "idToken": "<facebook OIDC id_token>" }
```

### Vérification du token ([FacebookVerifier](../src/Domain/Auth/OAuth/FacebookVerifier.php))

- **Flux `classic`** : POST `https://graph.facebook.com/v19.0/debug_token?input_token=…&access_token=<app_id>|<app_secret>` puis GET `/v19.0/me?fields=id,email`. Validations : `is_valid = true`, `app_id` correspond à `FACEBOOK_APP_ID`, `expires_at` strictement dans le futur **ou égal à `0`** (long-lived tokens), `profile.id` égal au `user_id` retourné par `debug_token`.
- **Flux `limited`** : JWKS `https://www.facebook.com/.well-known/oauth/openid/jwks/` (cache TTL 1h), `iss = https://www.facebook.com`, `aud = FACEBOOK_APP_ID`, skew 60s.

Dans les deux cas, l'email est **optionnel** — si l'utilisateur a refusé le scope `email`, la sortie normalisée [FacebookProfile](../src/Domain/Auth/OAuth/FacebookProfile.php) a `email = null`.

### Logique de résolution

| Cas | Résultat | Code | `meta.oauthOutcome` |
| --- | -------- | ---- | ------------------- |
| Pas d'email retourné (`email = null`) | refus, code `oauth.facebook.emailMissing` | `422` | — |
| Identité Facebook déjà liée | sign-in | `200` | `existingIdentity` |
| Email match user existant | refus auto-link (E.a) | `409 oauth.linkRefused` | — |
| Nouveau compte | création (`username = f_XXXXXXXX`) | `201` | `registered` |
| Banni | refus | `403` | — |

- **Réponse `401`** — `Invalid Facebook token` (signature/issuer/audience/expiration, ou `debug_token` refuse)
- **Réponse `403`** — `Account banned`
- **Réponse `409`** — `oauth.linkRefused`
- **Réponse `422`** — `flow` manquant/invalide, ou `accessToken`/`idToken` manquant selon le flow, ou `oauth.facebook.emailMissing`

### Notes

- Facebook **ne fournit aucun signal `email_verified`** sur ses APIs publiques. Hydrogen part du principe que l'email Facebook **n'est pas attesté** — d'où la politique stricte (E.a) et le refus d'auto-link sur email existant.
- Le name/profile.name de Facebook n'est **pas** récupéré : à l'inscription, on ne demande que `id` + `email`, et l'utilisateur complétera son profil ensuite (politique B.a).

---

## `POST /api/auth/login`

Échange un couple `email` + `password` contre un bearer token de session.

- **Auth** : aucune
- **Action** : [LoginAction](../src/Http/Action/Api/Auth/LoginAction.php)
- **Request body** — deux formes acceptées :

**Forme plate :**

```json
{
  "email": "user@example.com",
  "password": "secret"
}
```

**Forme JSON:API :**

```json
{
  "data": {
    "type": "credentials",
    "attributes": {
      "email": "user@example.com",
      "password": "secret"
    }
  }
}
```

- **Réponse `200`** — session créée :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username": "havoc",
      "nickname": "Havoc",
      "email": "user@example.com",
      "displayName": "Havoc",
      "userType": "member",
      "status": "active",
      "isVerified": false,
      "experience": 0,
      "level": 1,
      "levelProgress": 0.00,
      "isConfirmed": true,
      "isBanned": false
      /* + name, firstname, sex, birthdate, bio, joinedAt, ... */
    }
  },
  "meta": {
    "token": "<base64url-token>",
    "sessionId": "<session-uuid>",
    "expiresAt": "2026-07-06T12:34:56+00:00"
  }
}
```

- **Réponse `400`** — corps non-JSON ou non-objet
- **Réponse `401`** — `Invalid credentials` (email inconnu ou mauvais mot de passe — message volontairement ambigu pour ne pas faciliter l'énumération)
- **Réponse `403`** — `Account banned`, `meta.bannedUntil` indique la date de fin (ou `null` si permanent) **ou** `Email not confirmed`, `meta.code = "email.notConfirmed"` (compte créé mais email pas encore validé via `POST /api/auth/confirm-email`)
- **Réponse `422`** — un ou plusieurs `errors[]` avec `source.pointer = /data/attributes/email` ou `/data/attributes/password`
- **Réponse `429`** — `Too many login attempts`. Header `Retry-After: <seconds>` + `meta.retryAfterSeconds` + `meta.triggeredBy` (`"ip"` ou `"email"`). Voir [Rate limiting](#rate-limiting) ci-dessous.

- **Notes** :
  - Le mot de passe est re-hashé automatiquement si le coût bcrypt actuel (`12`) ne correspond pas au hash en base.
  - Le token brut n'est **jamais** stocké en base — seul son SHA-256 (32 octets) l'est.

### Rate limiting

Sliding window de **15 minutes**, deux buckets indépendants évalués en cascade (email d'abord, puis IP) :

| Bucket | Seuil       | Effet                                                                 |
| ------ | ----------- | --------------------------------------------------------------------- |
| email  | 5 échecs    | Bloque toute tentative pour cet email pendant le reste de la fenêtre. |
| ip     | 20 échecs   | Bloque toute tentative depuis cette IP pendant le reste de la fenêtre.|

- Seuls les **échecs** (`401 Invalid credentials`) incrémentent les compteurs. `400`/`422`/`403` ne comptent pas.
- Un login **réussi** vide le bucket email du compte (slate propre pour l'utilisateur légitime) ; le bucket IP reste — protège contre le credential spray multi-comptes.
- Réponse bloquée : `429` + header HTTP `Retry-After: <secondes>` + `meta.retryAfterSeconds` (même valeur) + `meta.triggeredBy` (`"email"` ou `"ip"`).

```json
{
  "jsonapi": { "version": "1.1" },
  "errors": [
    {
      "status": "429",
      "title":  "Too many login attempts",
      "detail": "Login is temporarily blocked. Try again later.",
      "meta": {
        "retryAfterSeconds": 612,
        "triggeredBy": "email"
      }
    }
  ]
}
```

Limites définies dans [LoginThrottle](../src/Domain/Auth/LoginThrottle.php) (`MAX_PER_EMAIL`, `MAX_PER_IP`, `WINDOW_MINUTES`). Stockage : table `login_attempt` ([migration](../database/migrations/2026_06_06_130000_create_login_attempt_table.sql)).

---

## `GET /api/auth/me`

Retourne l'utilisateur courant à partir du bearer token.

- **Auth** : **requise** (Bearer)
- **Action** : [MeAction](../src/Http/Action/Api/Auth/MeAction.php)
- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "users",
    "id": "<user-uuid>",
    "attributes": {
      "username": "havoc",
      "nickname": "Havoc",
      "email": "user@example.com",
      "displayName": "Havoc",
      "userType": "member",
      "status": "active",
      "isVerified": false,
      "experience": 0,
      "level": 1,
      "levelProgress": 0.00,
      "isConfirmed": true,
      "isBanned": false
    }
  }
}
```

- **Réponse `401`** — token absent / mal formé / expiré / révoqué
- **Effet de bord** : chaque appel rafraîchit `last_used_at` et fait glisser `expires_at` de +30 jours.

---

## `GET /api/auth/sessions`

Liste toutes les sessions **actives** (non expirées) de l'utilisateur courant, triées par dernier usage décroissant. Sert à alimenter une page « Mes sessions actives » avec un bouton de révocation par session.

- **Auth** : **requise** (Bearer)
- **Action** : [ListSessionsAction](../src/Http/Action/Api/Auth/ListSessionsAction.php)
- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "userSessions",
      "id": "<session-uuid-1>",
      "attributes": {
        "createdAt":  "2026-06-06T10:00:00+00:00",
        "lastUsedAt": "2026-06-06T12:30:00+00:00",
        "expiresAt":  "2026-07-06T12:30:00+00:00",
        "userAgent":  "Mozilla/5.0 (...)",
        "ipAddress":  "127.0.0.1",
        "isCurrent":  true
      }
    },
    {
      "type": "userSessions",
      "id": "<session-uuid-2>",
      "attributes": {
        "createdAt":  "2026-06-05T08:15:00+00:00",
        "lastUsedAt": "2026-06-05T20:00:00+00:00",
        "expiresAt":  "2026-07-05T20:00:00+00:00",
        "userAgent":  "Hydrogen-iOS/1.0",
        "ipAddress":  "2a01:e35:...",
        "isCurrent":  false
      }
    }
  ],
  "meta": { "count": 2 }
}
```

- **Réponse `401`** — token absent / invalide
- **Notes** :
  - `ipAddress` est rendu en format lisible (`inet_ntop` sur les octets stockés en `VARBINARY(16)`), IPv4 ou IPv6.
  - `isCurrent` flag la session associée au bearer token de la requête en cours — utile pour griser un bouton « révoquer » dans l'UI.
  - L'appel lui-même fait glisser `expiresAt` de la session courante (effet de bord normal du middleware d'auth).

---

## `DELETE /api/auth/sessions/{id}`

Révoque **une session précise** par son UUID. La session doit appartenir à l'utilisateur courant — sinon `404` (volontaire : ne fuite pas l'existence des sessions d'autres comptes). Si l'`{id}` cible la session courante, le token utilisé devient immédiatement invalide.

- **Auth** : **requise** (Bearer)
- **Action** : [RevokeSessionAction](../src/Http/Action/Api/Auth/RevokeSessionAction.php)
- **Path params** :
  - `id` *(string, UUID)* — id de la session à révoquer (typiquement obtenu via `GET /api/auth/sessions`).
- **Request body** : aucun
- **Réponse `204`** — révoquée avec succès, pas de contenu
- **Réponse `400`** — `{id}` n'est pas un UUID valide
- **Réponse `404`** — aucune session avec cet id n'appartient au user courant (id inconnu **ou** appartient à un autre user — même réponse pour ne pas révéler l'existence)
- **Réponse `401`** — token absent / invalide
- **Notes** :
  - Pour révoquer **toutes les autres sessions** d'un coup, utiliser plutôt `POST /api/auth/logout-all` (mais celui-ci tue aussi la session courante).
  - L'UI typique appelle cet endpoint depuis un bouton « Révoquer » sur chaque ligne de la liste rendue par `GET /api/auth/sessions`.

---

## `POST /api/auth/logout`

Révoque **uniquement la session courante** (celle associée au token utilisé).

- **Auth** : **requise** (Bearer)
- **Action** : [LogoutAction](../src/Http/Action/Api/Auth/LogoutAction.php)
- **Request body** : aucun
- **Réponse `204`** — pas de contenu
- **Réponse `401`** — token absent / invalide

---

## `POST /api/auth/logout-all`

Révoque **toutes les sessions** de l'utilisateur courant (déconnexion de tous les appareils). Le token utilisé pour cet appel est lui-même invalidé.

- **Auth** : **requise** (Bearer)
- **Action** : [LogoutAllAction](../src/Http/Action/Api/Auth/LogoutAllAction.php)
- **Request body** : aucun
- **Réponse `200`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "sessionRevocations",
    "id": "<user-uuid>",
    "attributes": {
      "revokedCount": 3
    }
  }
}
```

- **Réponse `401`** — token absent / invalide
- **Cas d'usage typiques** : bouton « Se déconnecter de tous les appareils », rotation de mot de passe, suspicion de compromission.

---
