1- import { ENV } from '@jetstream/api-config' ;
1+ import { ENV , logger } from '@jetstream/api-config' ;
22import { convertUserProfileToSession_External , InvalidAccessToken } from '@jetstream/auth/server' ;
3- import { UserProfileSession } from '@jetstream/auth/types' ;
3+ import { TokenSource , UserProfileSession } from '@jetstream/auth/types' ;
44import { HTTP } from '@jetstream/shared/constants' ;
55import { getErrorMessageAndStackObj } from '@jetstream/shared/utils' ;
66import { Maybe , UserProfileUi } from '@jetstream/types' ;
7+ import { randomUUID } from 'crypto' ;
8+ import { fromUnixTime } from 'date-fns' ;
79import * as express from 'express' ;
810import jwt from 'fast-jwt' ;
911import { LRUCache } from 'lru-cache' ;
1012import * as webExtDb from '../db/web-extension.db' ;
13+ import { hashToken } from '../services/jwt-token-encryption.service' ;
1114import { AuthenticationError } from '../utils/error-handler' ;
1215
1316const cache = new LRUCache < string , JwtDecodedPayload > ( { max : 500 } ) ;
@@ -16,8 +19,9 @@ export const AUDIENCE_WEB_EXT = 'https://getjetstream.app/web-extension';
1619export const AUDIENCE_DESKTOP = 'https://getjetstream.app/desktop-app' ;
1720const ISSUER = 'https://getjetstream.app' ;
1821
19- export const TOKEN_AUTO_REFRESH_DAYS = 7 ;
22+ export const TOKEN_AUTO_REFRESH_DAYS = 2 ;
2023const TOKEN_EXPIRATION = 60 * 60 * 24 * 90 * 1000 ; // 90 days
24+ export const TOKEN_EXPIRATION_SHORT = 60 * 60 * 24 * 7 * 1000 ; // 7 days
2125
2226export type Audience = typeof AUDIENCE_WEB_EXT | typeof AUDIENCE_DESKTOP ;
2327
@@ -30,7 +34,7 @@ export interface JwtDecodedPayload {
3034 exp : number ;
3135}
3236
33- function prepareJwtFns ( userId : string , durationMs , audience ) {
37+ function prepareJwtFns ( userId : string , durationMs : number , audience : string ) {
3438 const jwtSigner = jwt . createSigner ( {
3539 key : async ( ) => ENV . JETSTREAM_AUTH_WEB_EXT_JWT_SECRET ,
3640 algorithm : 'HS256' ,
@@ -54,12 +58,69 @@ function prepareJwtFns(userId: string, durationMs, audience) {
5458
5559async function generateJwt ( { payload, durationMs } : { payload : UserProfileUi ; durationMs : number } , audience : Audience ) {
5660 const { jwtSigner } = prepareJwtFns ( payload . id , durationMs , audience ) ;
57- const token = await jwtSigner ( { userProfile : payload } ) ;
61+ const token = await jwtSigner ( { userProfile : payload , jti : randomUUID ( ) } ) ;
5862 return token ;
5963}
6064
61- export async function issueAccessToken ( payload : UserProfileUi , audience : Audience ) {
62- return await generateJwt ( { payload, durationMs : TOKEN_EXPIRATION } , audience ) ;
65+ export async function issueAccessToken ( payload : UserProfileUi , audience : Audience , durationMs ?: number ) {
66+ return await generateJwt ( { payload, durationMs : durationMs ?? TOKEN_EXPIRATION } , audience ) ;
67+ }
68+
69+ export function invalidateCacheEntry ( accessToken : string , deviceId : string ) : void {
70+ const cacheKey = `${ accessToken } -${ deviceId } ` ;
71+ cache . delete ( cacheKey ) ;
72+ }
73+
74+ /**
75+ * Issue a new short-lived JWT, replace the old token in the DB, and invalidate the LRU cache.
76+ * Used by both desktop and web extension controllers during /auth/verify when the client
77+ * sends the X-Supports-Token-Rotation header.
78+ *
79+ * Uses a conditional update (checking the old tokenHash) to prevent a race where two
80+ * concurrent requests both rotate the same token — the second attempt returns undefined
81+ * instead of silently overwriting the first rotation's token.
82+ */
83+ export async function rotateToken ( {
84+ userProfile,
85+ audience,
86+ source,
87+ deviceId,
88+ oldAccessToken,
89+ ipAddress,
90+ userAgent,
91+ durationMs,
92+ } : {
93+ userProfile : UserProfileUi ;
94+ audience : Audience ;
95+ source : TokenSource ;
96+ deviceId : string ;
97+ oldAccessToken : string ;
98+ ipAddress : string ;
99+ userAgent : string ;
100+ durationMs ?: number ;
101+ } ) : Promise < string | undefined > {
102+ const newAccessToken = await issueAccessToken ( userProfile , audience , durationMs ?? TOKEN_EXPIRATION_SHORT ) ;
103+ const oldTokenHash = hashToken ( oldAccessToken ) ;
104+ const wasReplaced = await webExtDb . replaceTokenIfCurrent ( userProfile . id , oldTokenHash , {
105+ type : webExtDb . TOKEN_TYPE_AUTH ,
106+ source,
107+ token : newAccessToken ,
108+ deviceId,
109+ ipAddress,
110+ userAgent,
111+ expiresAt : fromUnixTime ( decodeToken ( newAccessToken ) . exp ) ,
112+ } ) ;
113+ // Always invalidate the old token from cache — whether we won or lost the race,
114+ // the old token hash is no longer current in the DB and should not be served from cache.
115+ invalidateCacheEntry ( oldAccessToken , deviceId ) ;
116+ if ( ! wasReplaced ) {
117+ // Another concurrent request already rotated this token — skip to avoid invalidating the winner's token.
118+ // Note: if the rotation response is lost (network failure), the client will hold a stale token and must re-login.
119+ // This is an accepted trade-off to avoid the complexity of dual-token grace periods.
120+ logger . warn ( { userId : userProfile . id , deviceId, audience } , 'rotateToken: race lost — token already rotated by another request' ) ;
121+ return undefined ;
122+ }
123+ return newAccessToken ;
63124}
64125
65126export function decodeToken ( token : string ) : JwtDecodedPayload {
@@ -154,7 +215,7 @@ export function getExternalAuthMiddleware(audience: Audience) {
154215 res . locals . deviceId = deviceId ;
155216 next ( ) ;
156217 } catch ( ex ) {
157- req . log . info ( '[DESKTOP-AUTH][ AUTH ERROR] Error decoding token' , ex ) ;
218+ req . log . info ( '[EXTERNAL AUTH ERROR] Error decoding token' , ex ) ;
158219 next ( new AuthenticationError ( 'Unauthorized' , { skipLogout : true } ) ) ;
159220 }
160221 } ;
0 commit comments