From 5d3408fafae33a12d12791d1758e34319eb0525c Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 15:05:06 +0200 Subject: [PATCH] feat(api): refresh tokens custom (cookie httpOnly + rotation panic-mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pattern hybride (cf. backend.md §7) : access token Bearer 30min en JSON + refresh token 30j en cookie httpOnly `rubis_refresh` géré custom au-dessus d'@adonisjs/auth qui ne ship pas de primitive refresh. Migration refresh_tokens (uuid id, user_id FK CASCADE, hashed_token unique [SHA-256, 64 chars hex], expires_at, last_used_at nullable, revoked_at nullable, ip_address, user_agent). Index user_id + expires_at. Service refresh_token.ts : - issueRefreshToken(user, ctx) : génère 32 bytes random → base64url → hash SHA-256 stocké, plain dans le cookie httpOnly + secure (en prod) + sameSite strict + path=/api/v1/auth (le browser n'envoie le cookie que sur les routes auth, pas chaque requête API). - consumeRefreshToken(ctx) : lookup par hash, validation expiry/revoked. Si on présente un token DÉJÀ révoqué, panic mode : tous les refresh tokens actifs du user sont invalidés (signal de vol — le vrai propriétaire devra se re-logger). - revokeCurrentRefreshToken / revokeAllForUser pour logout et le panic. Service auth_session.ts : factorise emitAuthSession(user, ctx) qui crée access + refresh + retourne l'AuthSession. Utilisé par signup / login / refresh — DRY. Controllers : - POST /auth/signup : emitAuthSession après tx (org + plans + user). - POST /auth/login : emitAuthSession après verifyCredentials. - POST /auth/refresh (nouveau) : consumeRefreshToken → emitAuthSession. Rotation : l'ancien token devient révoqué, le nouveau est posé. SPA-side : appelé au boot pour rehydrater + après 401 silencieux. - POST /account/logout : User.accessTokens.delete + revokeCurrentRefreshToken + clearCookie. CORS a déjà credentials: true → le cookie traverse cross-origin si origin allowed. Bruno : nouvelle requête `Auth/04 Refresh.bru` + folder doc + flow décrit dans README. Bruno honore la cookie jar nativement, donc aucun setup additionnel pour tester. ⚠️ Le contrôleur Refresh est nouveau → le registre Tuyau-généré .adonisjs/server/controllers.ts sera régénéré au prochain `pnpm dev:api` (la regen est un effet de bord du boot Adonis, on ne peut pas la déclencher seule). Avant ce premier boot, `pnpm typecheck` échouera sur l'absence de `controllers.Refresh` dans le registre. --- .../controllers/access_tokens_controller.ts | 35 ++-- .../app/controllers/new_account_controller.ts | 36 +---- .../api/app/controllers/refresh_controller.ts | 32 ++++ apps/api/app/models/refresh_token.ts | 9 ++ apps/api/app/services/auth_session.ts | 36 +++++ apps/api/app/services/refresh_token.ts | 151 ++++++++++++++++++ ...78080000800_create_refresh_tokens_table.ts | 42 +++++ apps/api/database/schema.ts | 25 +++ apps/api/start/routes.ts | 4 +- bruno/00-Auth/01 Signup.bru | 4 +- bruno/00-Auth/02 Login.bru | 5 +- bruno/00-Auth/03 Logout.bru | 7 +- bruno/00-Auth/04 Refresh.bru | 54 +++++++ bruno/00-Auth/folder.bru | 22 ++- bruno/README.md | 4 + 15 files changed, 405 insertions(+), 61 deletions(-) create mode 100644 apps/api/app/controllers/refresh_controller.ts create mode 100644 apps/api/app/models/refresh_token.ts create mode 100644 apps/api/app/services/auth_session.ts create mode 100644 apps/api/app/services/refresh_token.ts create mode 100644 apps/api/database/migrations/1778080000800_create_refresh_tokens_table.ts create mode 100644 bruno/00-Auth/04 Refresh.bru diff --git a/apps/api/app/controllers/access_tokens_controller.ts b/apps/api/app/controllers/access_tokens_controller.ts index 3414769..baad461 100644 --- a/apps/api/app/controllers/access_tokens_controller.ts +++ b/apps/api/app/controllers/access_tokens_controller.ts @@ -1,42 +1,33 @@ import User from '#models/user' import { loginValidator } from '#validators/user' import type { HttpContext } from '@adonisjs/core/http' -import UserTransformer from '#transformers/user_transformer' -import env from '#start/env' -import { DateTime } from 'luxon' +import { emitAuthSession } from '#services/auth_session' +import { revokeCurrentRefreshToken } from '#services/refresh_token' export default class AccessTokensController { /** - * POST /auth/login — renvoie une AuthSession pour le SPA. + * POST /auth/login — vérifie credentials + émet AuthSession. */ - async store({ request, serialize }: HttpContext) { - const { email, password } = await request.validateUsing(loginValidator) + async store(ctx: HttpContext) { + const { email, password } = await ctx.request.validateUsing(loginValidator) const user = await User.verifyCredentials(email, password) - const accessToken = await User.accessTokens.create(user) - - const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30) - const expiresAt = - accessToken.expiresAt?.toISOString() ?? - DateTime.now().plus({ minutes: ttlMin }).toISO()! - - return serialize({ - accessToken: accessToken.value!.release(), - expiresAt, - user: UserTransformer.transform(user), - }) + const session = await emitAuthSession(user, ctx) + return ctx.serialize(session) } /** - * POST /account/logout — révoque le token courant. + * POST /account/logout — révoque l'access token courant + le refresh + * token + clear le cookie. */ - async destroy({ auth, response }: HttpContext) { - const user = auth.getUserOrFail() + async destroy(ctx: HttpContext) { + const user = ctx.auth.getUserOrFail() if (user.currentAccessToken) { await User.accessTokens.delete(user, user.currentAccessToken.identifier) } + await revokeCurrentRefreshToken(ctx) - response.status(204) + ctx.response.status(204) return null } } diff --git a/apps/api/app/controllers/new_account_controller.ts b/apps/api/app/controllers/new_account_controller.ts index 442c66f..b4b2a69 100644 --- a/apps/api/app/controllers/new_account_controller.ts +++ b/apps/api/app/controllers/new_account_controller.ts @@ -2,27 +2,19 @@ import User from '#models/user' import Organization from '#models/organization' import { signupValidator } from '#validators/user' import type { HttpContext } from '@adonisjs/core/http' -import UserTransformer from '#transformers/user_transformer' import db from '@adonisjs/lucid/services/db' -import env from '#start/env' -import { DateTime } from 'luxon' import { provisionDefaultPlans } from '#services/default_plans' +import { emitAuthSession } from '#services/auth_session' export default class NewAccountController { /** * POST /auth/signup - * Crée une organisation vide + un user dans la même transaction, - * puis émet un access token et renvoie une AuthSession. - * - * Le nom de l'org reste vide ("") — c'est la première étape de - * l'onboarding qui le remplit (PATCH /organizations/me). + * Crée organisation + 4 plans pré-fournis + user dans une transaction, + * puis émet une AuthSession (access token JSON + refresh cookie httpOnly). */ - async store({ request, response, serialize }: HttpContext) { - const { fullName, email, password } = await request.validateUsing(signupValidator) + async store(ctx: HttpContext) { + const { fullName, email, password } = await ctx.request.validateUsing(signupValidator) - // org + user + 4 plans pré-fournis créés atomiquement, puis le token - // (qui passe par un client pg séparé via DbAccessTokensProvider — il - // doit voir l'user commit). const user = await db.transaction(async (trx) => { const org = await Organization.create({ name: '' }, { client: trx }) await provisionDefaultPlans(org.id, trx) @@ -32,20 +24,8 @@ export default class NewAccountController { ) }) - const accessToken = await User.accessTokens.create(user) - - const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30) - const expiresAt = - accessToken.expiresAt?.toISOString() ?? - DateTime.now().plus({ minutes: ttlMin }).toISO()! - - response.status(201) - // serialize() ajoute lui-même le wrap { data: ... } et unwrap les Items - // qu'il trouve aux clés directes — donc on lui passe les champs à plat. - return serialize({ - accessToken: accessToken.value!.release(), - expiresAt, - user: UserTransformer.transform(user), - }) + const session = await emitAuthSession(user, ctx) + ctx.response.status(201) + return ctx.serialize(session) } } diff --git a/apps/api/app/controllers/refresh_controller.ts b/apps/api/app/controllers/refresh_controller.ts new file mode 100644 index 0000000..a23b7f6 --- /dev/null +++ b/apps/api/app/controllers/refresh_controller.ts @@ -0,0 +1,32 @@ +import type { HttpContext } from '@adonisjs/core/http' +import { Exception } from '@adonisjs/core/exceptions' +import { consumeRefreshToken } from '#services/refresh_token' +import { emitAuthSession } from '#services/auth_session' + +export default class RefreshController { + /** + * POST /auth/refresh + * + * Lit le cookie `rubis_refresh` (httpOnly), valide son hash en DB, + * révoque l'ancien et émet une AuthSession fraîche (nouveau access + * token + nouveau refresh cookie posé via emitAuthSession). + * + * Codes d'erreur : + * - 401 no_session : pas de cookie envoyé + * - 401 session_expired : cookie inconnu, expiré, ou révoqué + * (réutilisation d'un token révoqué = vol présumé → panic mode : + * tous les tokens actifs du user sont invalidés) + */ + async handle(ctx: HttpContext) { + const result = await consumeRefreshToken(ctx) + if ('errorCode' in result) { + throw new Exception( + result.errorCode === 'no_session' ? 'Pas de session active' : 'Session expirée', + { status: 401, code: result.errorCode } + ) + } + + const session = await emitAuthSession(result.user, ctx) + return ctx.serialize(session) + } +} diff --git a/apps/api/app/models/refresh_token.ts b/apps/api/app/models/refresh_token.ts new file mode 100644 index 0000000..f8c3ad7 --- /dev/null +++ b/apps/api/app/models/refresh_token.ts @@ -0,0 +1,9 @@ +import { RefreshTokenSchema } from '#database/schema' +import { belongsTo } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import User from '#models/user' + +export default class RefreshToken extends RefreshTokenSchema { + @belongsTo(() => User) + declare user: BelongsTo +} diff --git a/apps/api/app/services/auth_session.ts b/apps/api/app/services/auth_session.ts new file mode 100644 index 0000000..841bb46 --- /dev/null +++ b/apps/api/app/services/auth_session.ts @@ -0,0 +1,36 @@ +import { DateTime } from 'luxon' +import User from '#models/user' +import UserTransformer from '#transformers/user_transformer' +import env from '#start/env' +import { issueRefreshToken } from '#services/refresh_token' +import type { HttpContext } from '@adonisjs/core/http' + +/** + * Émet une AuthSession complète : access token en JSON + refresh token + * en cookie httpOnly. Utilisé par signup et login. + * + * Format de réponse aligné sur packages/shared/src/types/auth.ts : + * `{ data: { accessToken, expiresAt, user } }` + */ +export async function emitAuthSession( + user: User, + ctx: HttpContext +): Promise<{ + accessToken: string + expiresAt: string + user: ReturnType +}> { + const accessToken = await User.accessTokens.create(user) + await issueRefreshToken(user, ctx) + + const ttlMin = env.get('ACCESS_TOKEN_TTL_MINUTES', 30) + const expiresAt = + accessToken.expiresAt?.toISOString() ?? + DateTime.now().plus({ minutes: ttlMin }).toISO()! + + return { + accessToken: accessToken.value!.release(), + expiresAt, + user: new UserTransformer(user).toObject(), + } +} diff --git a/apps/api/app/services/refresh_token.ts b/apps/api/app/services/refresh_token.ts new file mode 100644 index 0000000..d94d052 --- /dev/null +++ b/apps/api/app/services/refresh_token.ts @@ -0,0 +1,151 @@ +import crypto from 'node:crypto' +import { DateTime } from 'luxon' +import RefreshToken from '#models/refresh_token' +import User from '#models/user' +import env from '#start/env' +import type { HttpContext } from '@adonisjs/core/http' + +export const REFRESH_COOKIE_NAME = 'rubis_refresh' + +/** + * Génère un token plain (32 bytes random → base64url ~43 chars), retourne + * { plain, hashed } pour ne stocker que le hashed côté DB. + * + * SHA-256 suffit : le token est un opaque random non humain, pas un mot + * de passe — pas besoin de bcrypt/argon (contrairement aux passwords). + */ +function generateToken(): { plain: string; hashed: string } { + const plain = crypto.randomBytes(32).toString('base64url') + const hashed = crypto.createHash('sha256').update(plain).digest('hex') + return { plain, hashed } +} + +function hashToken(plain: string): string { + return crypto.createHash('sha256').update(plain).digest('hex') +} + +function ttlDays(): number { + return env.get('REFRESH_TOKEN_TTL_DAYS', 30) +} + +/** + * Pose le cookie httpOnly avec le token plain. Le SPA ne peut pas le lire + * en JS — c'est ce qui le protège du XSS, contrairement à localStorage. + * + * `path: /api/v1/auth` : le browser n'envoie le cookie qu'aux endpoints + * d'auth, pas sur chaque requête API. Réduit la surface d'attaque CSRF. + */ +function setRefreshCookie(ctx: HttpContext, plain: string) { + const maxAgeSeconds = ttlDays() * 24 * 60 * 60 + ctx.response.cookie(REFRESH_COOKIE_NAME, plain, { + httpOnly: true, + secure: env.get('COOKIE_SECURE', false), + sameSite: 'strict', + path: '/api/v1/auth', + domain: env.get('COOKIE_DOMAIN') || undefined, + maxAge: maxAgeSeconds, + }) +} + +function clearRefreshCookie(ctx: HttpContext) { + ctx.response.clearCookie(REFRESH_COOKIE_NAME, { + path: '/api/v1/auth', + domain: env.get('COOKIE_DOMAIN') || undefined, + }) +} + +/** + * Crée un refresh token pour un user et pose le cookie correspondant. + * Appelé après signup et login. + */ +export async function issueRefreshToken( + user: User, + ctx: HttpContext +): Promise<{ token: RefreshToken; plain: string }> { + const { plain, hashed } = generateToken() + + const token = await RefreshToken.create({ + userId: user.id, + hashedToken: hashed, + expiresAt: DateTime.now().plus({ days: ttlDays() }), + lastUsedAt: null, + revokedAt: null, + ipAddress: ctx.request.ip(), + userAgent: ctx.request.header('user-agent') ?? null, + }) + + setRefreshCookie(ctx, plain) + return { token, plain } +} + +/** + * Valide le cookie reçu et révoque l'ancien token. Retourne le user + * authentifié — le contrôleur appelle ensuite `issueRefreshToken` (via + * emitAuthSession) pour poser un nouveau cookie. Rotation complète. + * + * Si le user envoie un token déjà révoqué, on suppose un vol potentiel + * et on révoque TOUS les tokens actifs du user (panic mode). + */ +export async function consumeRefreshToken( + ctx: HttpContext +): Promise<{ user: User } | { errorCode: 'no_session' | 'session_expired' }> { + const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME) + if (!cookie) return { errorCode: 'no_session' } + + const hashed = hashToken(cookie) + const stored = await RefreshToken.query().where('hashed_token', hashed).first() + + if (!stored) { + clearRefreshCookie(ctx) + return { errorCode: 'session_expired' } + } + + // Token déjà révoqué = signal de vol potentiel : on coupe tout pour + // l'user concerné. Le vrai propriétaire devra se re-logger. + if (stored.revokedAt) { + await revokeAllForUser(stored.userId) + clearRefreshCookie(ctx) + return { errorCode: 'session_expired' } + } + + if (stored.expiresAt < DateTime.now()) { + stored.revokedAt = DateTime.now() + await stored.save() + clearRefreshCookie(ctx) + return { errorCode: 'session_expired' } + } + + stored.revokedAt = DateTime.now() + stored.lastUsedAt = DateTime.now() + await stored.save() + + const user = await User.findOrFail(stored.userId) + return { user } +} + +/** + * Révoque le token courant (utilisé par /account/logout). + * Pas de panic — l'user demande explicitement la déconnexion. + */ +export async function revokeCurrentRefreshToken(ctx: HttpContext): Promise { + const cookie = ctx.request.cookie(REFRESH_COOKIE_NAME) + if (cookie) { + const hashed = hashToken(cookie) + await RefreshToken.query() + .where('hashed_token', hashed) + .whereNull('revoked_at') + .update({ revoked_at: DateTime.now().toSQL() }) + } + clearRefreshCookie(ctx) +} + +/** + * Révoque tous les tokens d'un user (panic mode si vol détecté, ou + * appelable par "déconnecter toutes mes sessions"). + */ +export async function revokeAllForUser(userId: string): Promise { + await RefreshToken.query() + .where('user_id', userId) + .whereNull('revoked_at') + .update({ revoked_at: DateTime.now().toSQL() }) +} diff --git a/apps/api/database/migrations/1778080000800_create_refresh_tokens_table.ts b/apps/api/database/migrations/1778080000800_create_refresh_tokens_table.ts new file mode 100644 index 0000000..9f5c91f --- /dev/null +++ b/apps/api/database/migrations/1778080000800_create_refresh_tokens_table.ts @@ -0,0 +1,42 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'refresh_tokens' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()')) + table + .uuid('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + + // SHA-256 du token plain (64 chars hex). On stocke le hash, pas le + // plain — protection en cas de fuite de la DB. Le hash est unique + // pour permettre un lookup rapide en O(1) sur (hash) lors du refresh. + table.string('hashed_token', 64).notNullable().unique() + + table.timestamp('expires_at').notNullable() + table.timestamp('last_used_at').nullable() + table.timestamp('revoked_at').nullable() + + // Audit/forensique : utile pour invalider une session suspecte + // (ip soudaine depuis un autre pays, etc.). Pas affiché dans l'UI V1. + table.string('ip_address', 64).nullable() + table.string('user_agent', 500).nullable() + + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + + // Index pour les rotations rapides + nettoyage des tokens expirés + table.index(['user_id']) + table.index(['expires_at']) + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 8b49d89..1ce4c39 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -199,6 +199,31 @@ export class PlanSchema extends BaseModel { declare updatedAt: DateTime | null } +export class RefreshTokenSchema extends BaseModel { + static $columns = ['createdAt', 'expiresAt', 'hashedToken', 'id', 'ipAddress', 'lastUsedAt', 'revokedAt', 'updatedAt', 'userAgent', 'userId'] as const + $columns = RefreshTokenSchema.$columns + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column.dateTime() + declare expiresAt: DateTime + @column() + declare hashedToken: string + @column({ isPrimary: true }) + declare id: string + @column() + declare ipAddress: string | null + @column.dateTime() + declare lastUsedAt: DateTime | null + @column.dateTime() + declare revokedAt: DateTime | null + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null + @column() + declare userAgent: string | null + @column() + declare userId: string +} + export class UserSchema extends BaseModel { static $columns = ['createdAt', 'email', 'fullName', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const $columns = UserSchema.$columns diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 5bbdd88..45d2267 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -20,12 +20,14 @@ router.get('/', () => { router .group(() => { /** - * Auth — public. + * Auth — public. /refresh utilise le cookie httpOnly `rubis_refresh` + * posé par signup/login pour émettre une nouvelle AuthSession. */ router .group(() => { router.post('signup', [controllers.NewAccount, 'store']).as('signup') router.post('login', [controllers.AccessTokens, 'store']).as('login') + router.post('refresh', [controllers.Refresh, 'handle']).as('refresh') }) .prefix('auth') .as('auth') diff --git a/bruno/00-Auth/01 Signup.bru b/bruno/00-Auth/01 Signup.bru index d4ff6dd..ddd6acc 100644 --- a/bruno/00-Auth/01 Signup.bru +++ b/bruno/00-Auth/01 Signup.bru @@ -46,7 +46,9 @@ docs { Crée une organisation vide + un user + duplique les 4 plans pré-fournis (Standard B2B / Rapide / Patient / Ferme) dans la même transaction. Émet - ensuite un access token (TTL 30 min). + ensuite une AuthSession : + - access token (TTL 30 min) en JSON + - refresh token (TTL 30 jours) en cookie httpOnly `rubis_refresh` Le nom de l'organisation reste `""` jusqu'à ce que l'utilisateur passe l'onboarding via PATCH /organizations/me. diff --git a/bruno/00-Auth/02 Login.bru b/bruno/00-Auth/02 Login.bru index f43aae0..e6cace7 100644 --- a/bruno/00-Auth/02 Login.bru +++ b/bruno/00-Auth/02 Login.bru @@ -36,8 +36,9 @@ tests { docs { POST /api/v1/auth/login - Émet un nouveau access token. Pratique pour récupérer un token sans - re-signup (l'email/password de fixture restent les mêmes entre runs). + Émet une AuthSession (access token JSON + refresh cookie httpOnly). + Pratique pour récupérer un token sans re-signup (l'email/password de + fixture restent les mêmes entre runs). Erreurs : - 422 validation_failed (email/password manquants) diff --git a/bruno/00-Auth/03 Logout.bru b/bruno/00-Auth/03 Logout.bru index 14a9e15..edc72f2 100644 --- a/bruno/00-Auth/03 Logout.bru +++ b/bruno/00-Auth/03 Logout.bru @@ -29,8 +29,11 @@ tests { docs { POST /api/v1/account/logout - Révoque le access token courant. Réponse 204 sans body. + Révoque le access token courant ET le refresh token associé, et clear + le cookie `rubis_refresh`. Réponse 204 sans body. Le script post-réponse vide la variable `token` pour que les requêtes - suivantes échouent en 401 (test du flow déconnexion). + suivantes échouent en 401 (test du flow déconnexion). Bruno purge + automatiquement le cookie via le `Set-Cookie` de réponse avec maxAge + négatif. } diff --git a/bruno/00-Auth/04 Refresh.bru b/bruno/00-Auth/04 Refresh.bru new file mode 100644 index 0000000..0ac12a8 --- /dev/null +++ b/bruno/00-Auth/04 Refresh.bru @@ -0,0 +1,54 @@ +meta { + name: 04 Refresh + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/v1/auth/refresh + body: none + auth: none +} + +script:post-response { + if (res.getStatus() === 200) { + const session = res.getBody().data; + bru.setEnvVar("token", session.accessToken); + } +} + +tests { + test("200 OK", function () { + expect(res.getStatus()).to.equal(200); + }); + test("nouveau accessToken émis", function () { + expect(res.getBody().data).to.have.property("accessToken"); + expect(res.getBody().data).to.have.property("expiresAt"); + }); +} + +docs { + POST /api/v1/auth/refresh + + Échange le cookie httpOnly `rubis_refresh` (posé automatiquement par + Signup ou Login) contre une nouvelle AuthSession : + - nouveau access token JSON (TTL 30 min) + - nouveau refresh cookie (rotation : l'ancien est révoqué) + + Bruno gère la cookie jar automatiquement → pas besoin d'envoyer le + cookie manuellement, il est posé par signup/login et présent ici. + + Sécurité — rotation : + - Si on présente un cookie déjà révoqué (signal de vol), TOUS les + refresh tokens actifs du user sont invalidés (panic mode). + - Le hash SHA-256 est stocké en DB, jamais le plain. + - Cookie scope : `path=/api/v1/auth` → pas envoyé sur les autres routes. + + Erreurs : + - 401 no_session : pas de cookie envoyé + - 401 session_expired : cookie inconnu / expiré / révoqué + + Côté SPA : appelé automatiquement au boot pour rehydrater la session + sans demander un re-login. Et au moment où une requête API renvoie 401, + le SPA tente un silent refresh puis retry. +} diff --git a/bruno/00-Auth/folder.bru b/bruno/00-Auth/folder.bru index fbd6692..585bc37 100644 --- a/bruno/00-Auth/folder.bru +++ b/bruno/00-Auth/folder.bru @@ -4,13 +4,25 @@ meta { } docs { - ## Auth — public (pas d'auth Bearer requise) + ## Auth — public (pas d'auth Bearer requise sur signup/login/refresh) - - **Signup** crée organisation + user + provisionne les 4 plans pré-fournis dans une transaction, puis émet un access token. - - **Login** vérifie email/password et émet un nouveau token. - - **Logout** révoque le token courant (auth requise — c'est volontairement dans le dossier Auth pour rester groupé sémantiquement). + Pattern hybride : + - **Access token** : Bearer 30min, transporté en header `Authorization`, + stocké en mémoire SPA. + - **Refresh token** : 30 jours, cookie httpOnly `rubis_refresh` scope + `path=/api/v1/auth`, sameSite strict. Rotation à chaque refresh. + + Endpoints : + - **01 Signup** — crée org + 4 plans pré-fournis + user dans une tx, + émet AuthSession (token JSON + cookie). + - **02 Login** — émet AuthSession. + - **03 Logout** — révoque l'access token + le refresh + clear le cookie. + - **04 Refresh** — échange le cookie contre une AuthSession fraîche + (rotation). Le SPA l'appelle au boot et après chaque 401. La réponse `AuthSession` est : `{ data: { accessToken, expiresAt, user } }`. - Le script post-réponse de Signup/Login capture `token`, `userId`, `organizationId` dans l'environnement actif. + Le script post-réponse capture `token`, `userId`, `organizationId` + dans l'env Bruno actif. Le cookie refresh est géré automatiquement + par la cookie jar de Bruno. } diff --git a/bruno/README.md b/bruno/README.md index 3b00d96..8b8e2ef 100644 --- a/bruno/README.md +++ b/bruno/README.md @@ -49,6 +49,10 @@ Définies dans `environments/local.bru`. Les valeurs **vides** (token, userId, e 12. **Imports → 02 Get batch** (review des drafts pending) 13. **Imports → 03 Validate draft** (transforme le draft en facture) +### Flow refresh (silent re-login) + +Une fois Signup ou Login passé, Bruno a stocké le cookie `rubis_refresh` dans sa cookie jar. Tu peux **vider `token` dans l'env actif** (pour simuler une expiration), puis appeler **Auth → 04 Refresh** : tu reçois un nouveau access token sans devoir re-saisir email/password. C'est ce que le SPA fera silencieusement quand une requête revient en 401. + ## Reset entre runs L'email `alice@bruno.test` est unique en DB → 2e signup retourne 422 `email_taken`.