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. * * `sameSite: 'lax'` (et pas 'strict') : `strict` bloque l'envoi du cookie * sur les navigations top-level (URL tapée, bookmark, restauration * d'onglets) — surtout Safari iOS — et casse le bootstrap SPA après * fermeture/réouverture du navigateur. Lax envoie le cookie sur les * top-level GET (qui sont safe) tout en bloquant les requêtes cross-site * embedded → protection CSRF préservée pour le POST /auth/refresh. */ 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: 'lax', 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 } } /** * Fenêtre de grâce après révocation pendant laquelle on tolère la * réutilisation d'un token révoqué SI un successeur actif existe — pour * accommoder les courses entre onglets (deux refresh parallèles avec le * même cookie). Au-delà : présomption de vol → panic mode. */ const ROTATION_GRACE_SECONDS = 60 /** * 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é : * - et qu'un successeur actif a été créé pour le même user dans les * {ROTATION_GRACE_SECONDS} dernières secondes → c'est très probablement * un refresh parallèle d'un autre onglet, pas un vol. On rotate ce * successeur et on retourne la session normalement (les 2 onglets * restent connectés). * - sinon, c'est une réutilisation tardive → présomption de vol, on * coupe TOUS les tokens actifs du user (panic mode). Le vrai * propriétaire devra se re-logger. */ 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 révoqué : check pour course entre onglets avant de paniquer. if (stored.revokedAt) { const graceCutoff = stored.revokedAt.plus({ seconds: ROTATION_GRACE_SECONDS }) const successor = await RefreshToken.query() .where('user_id', stored.userId) .whereNull('revoked_at') .where('expires_at', '>', DateTime.now().toSQL()) .where('created_at', '>=', stored.revokedAt.toSQL()) .where('created_at', '<=', graceCutoff.toSQL()) .orderBy('created_at', 'desc') .first() if (successor) { // Course détectée — rotate le successeur et continue normalement. successor.revokedAt = DateTime.now() successor.lastUsedAt = DateTime.now() await successor.save() const user = await User.findOrFail(successor.userId) return { user } } // Vrai vol présumé : tout révoquer. 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() }) }