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 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-08-02 22:38:58 +02:00
parent 9114e3376a
commit 3528295c68
2 changed files with 78 additions and 14 deletions

View File

@ -28,6 +28,8 @@ export interface RefreshToken
token: string; token: string;
userId: string; userId: string;
expiresAt: string; expiresAt: string;
usedAt?: string;
replacedBy?: string;
} }
export interface ResetToken export interface ResetToken
@ -103,11 +105,26 @@ export const refreshTokens =
expiresAt: new Date( Date.now() + ttlMs ).toISOString() expiresAt: new Date( Date.now() + ttlMs ).toISOString()
}; };
write( 'refreshTokens', [ ...read<RefreshToken>( '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<RefreshToken>( 'refreshTokens' ).filter( t => new Date( t.expiresAt ) >= new Date() );
write( 'refreshTokens', [ ...rows, token ] );
return 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<RefreshToken>( 'refreshTokens' ).map( t =>
t.token === token ? { ...t, usedAt: new Date().toISOString(), replacedBy } : t
);
write( 'refreshTokens', rows );
},
delete( token: string ): void delete( token: string ): void
{ {
write( 'refreshTokens', read<RefreshToken>( 'refreshTokens' ).filter( t => t.token !== token ) ); write( 'refreshTokens', read<RefreshToken>( 'refreshTokens' ).filter( t => t.token !== token ) );

View File

@ -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 = ( process.env.ACCESS_TOKEN_TTL ?? '1h' ) as any;
const ACCESS_TOKEN_TTL_MS = parseTtlMs( process.env.ACCESS_TOKEN_TTL ?? '1h' ); 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_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 COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.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'; const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
@ -245,9 +246,40 @@ router.post( '/refresh',
return; 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 ); RJLog.log( "Refreshing token", user.id, tokenData );
@ -410,6 +442,21 @@ 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 <accessToken>; 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 // GET /api/auth/me
router.get( '/me', requireAuth, router.get( '/me', requireAuth,