feat(api): refresh tokens custom (cookie httpOnly + rotation panic-mode)
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.
This commit is contained in:
parent
c7714e3e8a
commit
5d3408fafa
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
32
apps/api/app/controllers/refresh_controller.ts
Normal file
32
apps/api/app/controllers/refresh_controller.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
9
apps/api/app/models/refresh_token.ts
Normal file
9
apps/api/app/models/refresh_token.ts
Normal file
@ -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<typeof User>
|
||||
}
|
||||
36
apps/api/app/services/auth_session.ts
Normal file
36
apps/api/app/services/auth_session.ts
Normal file
@ -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<UserTransformer['toObject']>
|
||||
}> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
151
apps/api/app/services/refresh_token.ts
Normal file
151
apps/api/app/services/refresh_token.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
await RefreshToken.query()
|
||||
.where('user_id', userId)
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: DateTime.now().toSQL() })
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
54
bruno/00-Auth/04 Refresh.bru
Normal file
54
bruno/00-Auth/04 Refresh.bru
Normal file
@ -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.
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user