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:
parent
9114e3376a
commit
3528295c68
|
|
@ -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<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;
|
||||
},
|
||||
|
||||
// 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
|
||||
{
|
||||
write( 'refreshTokens', read<RefreshToken>( 'refreshTokens' ).filter( t => t.token !== token ) );
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
router.get( '/me', requireAuth,
|
||||
router.get( '/me', requireAuth,
|
||||
|
||||
( req, res ) =>
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue