From 3528295c689c6ed803394df2b84decffdc90022f Mon Sep 17 00:00:00 2001 From: Rokojori Date: Sun, 2 Aug 2026 22:38:58 +0200 Subject: [PATCH] feat: refresh token grace window + POST /api/auth/new-session Grace window: /api/auth/refresh no longer hard-401s on a concurrent reuse of the same refresh token. RefreshToken gains usedAt/replacedBy; markUsed() soft-deletes instead of hard-deleting; within REFRESH_GRACE_TTL (10s) a second use of the same token resolves to the same replacement pair. Fixes browser tabs racing the same single-use token on access-token expiry. new-session: POST /api/auth/new-session (requireAuth-guarded) mints a fresh independent token pair from an existing valid access token. Used by Electron instances on startup to avoid the login screen when another instance is already running (via session-heartbeat.json). Co-Authored-By: Claude Sonnet 4.6 --- source/server/db.ts | 21 ++++++++++- source/server/routes/auth.ts | 71 ++++++++++++++++++++++++++++++------ 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/source/server/db.ts b/source/server/db.ts index 9b2506c..61ece63 100644 --- a/source/server/db.ts +++ b/source/server/db.ts @@ -28,6 +28,8 @@ export interface RefreshToken token: string; userId: string; expiresAt: string; + usedAt?: string; + replacedBy?: string; } export interface ResetToken @@ -103,11 +105,26 @@ export const refreshTokens = expiresAt: new Date( Date.now() + ttlMs ).toISOString() }; - write( 'refreshTokens', [ ...read( 'refreshTokens' ), token ] ); - + // Prune rows that are fully expired — used-and-superseded rows are kept + // (their expiresAt is unchanged) so grace-window lookups still find them. + const rows = read( 'refreshTokens' ).filter( t => new Date( t.expiresAt ) >= new Date() ); + + write( 'refreshTokens', [ ...rows, token ] ); + return token; }, + // Marks a token as rotated instead of deleting it, so a near-simultaneous + // second refresh call using the same token can still be resolved to the + // replacement pair within the grace window (see REFRESH_GRACE_MS in auth.ts). + markUsed( token: string, replacedBy: string ): void + { + const rows = read( 'refreshTokens' ).map( t => + t.token === token ? { ...t, usedAt: new Date().toISOString(), replacedBy } : t + ); + write( 'refreshTokens', rows ); + }, + delete( token: string ): void { write( 'refreshTokens', read( 'refreshTokens' ).filter( t => t.token !== token ) ); diff --git a/source/server/routes/auth.ts b/source/server/routes/auth.ts index 2b5406a..b034cbc 100644 --- a/source/server/routes/auth.ts +++ b/source/server/routes/auth.ts @@ -25,6 +25,7 @@ function parseTtlMs( ttl: string ): number const ACCESS_TOKEN_TTL = ( process.env.ACCESS_TOKEN_TTL ?? '1h' ) as any; const ACCESS_TOKEN_TTL_MS = parseTtlMs( process.env.ACCESS_TOKEN_TTL ?? '1h' ); const REFRESH_TOKEN_TTL_MS = parseTtlMs( process.env.REFRESH_TOKEN_TTL ?? '30d' ); +const REFRESH_GRACE_MS = parseTtlMs( process.env.REFRESH_GRACE_TTL ?? '10s' ); const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com'; @@ -234,25 +235,56 @@ router.post( '/refresh', return; } - - const user = users.findById( record.userId ); - - if ( ! user ) - { - RJLog.log( "User not found", refreshToken, record ); - res.status( 401 ).json( { error: 'User not found' } ); - return; + const user = users.findById( record.userId ); + + if ( ! user ) + { + RJLog.log( "User not found", refreshToken, record ); + res.status( 401 ).json( { error: 'User not found' } ); + + return; } - refreshTokens.delete( refreshToken ); + // Already rotated by a concurrent request. Within the grace window, resolve + // to the same replacement pair instead of 401ing — this is what makes several + // near-simultaneous refresh calls (e.g. parallel tabs/panels racing the same + // expired access token) all succeed instead of only the first one winning. + if ( record.replacedBy ) + { + const usedMsAgo = record.usedAt ? Date.now() - new Date( record.usedAt ).getTime() : Infinity; - let tokenData = issueTokenPair( res, user.id ); + if ( usedMsAgo > REFRESH_GRACE_MS ) + { + RJLog.log( "Refresh token reused outside grace window", refreshToken ); + + res.status( 401 ).json( { error: 'Invalid or expired refresh token' } ); + + return; + } + + const accessToken = issueAccessToken( user.id ); + + setTokenCookie( res, accessToken ); + setRefreshTokenCookie( res, record.replacedBy ); + + const tokenData = { accessToken, refreshToken: record.replacedBy }; + + RJLog.log( "Refresh grace-window hit, reusing replacement", user.id, tokenData ); + + res.json( tokenData ); + + return; + } + + const tokenData = issueTokenPair( res, user.id ); + + refreshTokens.markUsed( refreshToken, tokenData.refreshToken ); RJLog.log( "Refreshing token", user.id, tokenData ); res.json( tokenData ); - } + } ); @@ -410,8 +442,23 @@ router.post( '/lookup-email', } ); +// POST /api/auth/new-session — Electron: mint a fresh independent session from an existing +// valid access token. Each Electron instance calls this on startup when it detects a live +// heartbeat from another running instance, so it gets its own token pair without the user +// having to log in again. The caller sends Authorization: Bearer ; the response +// body contains the new pair which the caller stores and uses from that point on. +router.post( '/new-session', requireAuth, + + ( req, res ) => + { + const tokenData = issueTokenPair( res, req.auth!.userId ); + res.json( tokenData ); + } + +); + // GET /api/auth/me -router.get( '/me', requireAuth, +router.get( '/me', requireAuth, ( req, res ) => {