rubis/apps/api/app/services/refresh_token.ts
ordinarthur 5d3408fafa 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.
2026-05-06 15:05:06 +02:00

152 lines
4.7 KiB
TypeScript

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() })
}