diff --git a/apps/api/app/services/refresh_token.ts b/apps/api/app/services/refresh_token.ts index d94d052..22321ff 100644 --- a/apps/api/app/services/refresh_token.ts +++ b/apps/api/app/services/refresh_token.ts @@ -34,13 +34,20 @@ function ttlDays(): number { * * `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: 'strict', + sameSite: 'lax', path: '/api/v1/auth', domain: env.get('COOKIE_DOMAIN') || undefined, maxAge: maxAgeSeconds, @@ -78,13 +85,28 @@ export async function issueRefreshToken( 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é, on suppose un vol potentiel - * et on révoque TOUS les tokens actifs du user (panic mode). + * 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 @@ -100,9 +122,28 @@ export async function consumeRefreshToken( 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. + // 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' } diff --git a/apps/web/index.html b/apps/web/index.html index f528865..6595ec2 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -20,6 +20,13 @@