99import { HTTP } from '@jetstream/shared/constants' ;
1010import { getErrorMessageAndStackObj } from '@jetstream/shared/utils' ;
1111import { fromUnixTime } from 'date-fns' ;
12+ import { UserProfileUiSchema } from '@jetstream/types' ;
1213import { z } from 'zod' ;
1314import { routeDefinition as dataSyncController } from '../controllers/data-sync.controller' ;
1415import * as userSyncDbService from '../db/data-sync.db' ;
@@ -55,7 +56,17 @@ export const routeDefinition = {
5556 } ,
5657 verifyToken : {
5758 controllerFn : ( ) => verifyToken ,
58- responseType : z . object ( { success : z . boolean ( ) , error : z . string ( ) . nullish ( ) } ) ,
59+ responseType : z . discriminatedUnion ( 'success' , [
60+ z . object ( {
61+ success : z . literal ( true ) ,
62+ userProfile : UserProfileUiSchema ,
63+ accessToken : z . string ( ) . optional ( ) ,
64+ } ) ,
65+ z . object ( {
66+ success : z . literal ( false ) ,
67+ error : z . string ( ) . nullish ( ) ,
68+ } ) ,
69+ ] ) ,
5970 validators : {
6071 /**
6172 * @deprecated , prefer headers for passing deviceId and accessToken
@@ -143,7 +154,11 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({
143154 }
144155
145156 // Issue new token if none exists or about to expire
146- const accessToken = await externalAuthService . issueAccessToken ( userProfile , externalAuthService . AUDIENCE_WEB_EXT ) ;
157+ const accessToken = await externalAuthService . issueAccessToken (
158+ userProfile ,
159+ externalAuthService . AUDIENCE_WEB_EXT ,
160+ externalAuthService . TOKEN_EXPIRATION_SHORT ,
161+ ) ;
147162 await webExtDb . create ( user . id , {
148163 type : webExtDb . TOKEN_TYPE_AUTH ,
149164 source : webExtDb . TOKEN_SOURCE_BROWSER_EXTENSION ,
@@ -162,30 +177,62 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({
162177 } ) ;
163178} ) ;
164179
165- const verifyToken = createRoute ( routeDefinition . verifyToken . validators , async ( { user } , _ , res ) => {
180+ const verifyToken = createRoute ( routeDefinition . verifyToken . validators , async ( { user } , req , res ) => {
166181 const { deviceId } = res . locals ;
167182 try {
168183 if ( ! user ) {
169184 throw new InvalidSession ( ) ;
170185 }
171186 const userProfile = await userDbService . findIdByUserIdUserFacing ( { userId : user . id , omitSubscriptions : true } ) ;
172- res . log . info ( { userId : userProfile . id , deviceId } , 'Web extension token verified' ) ;
173187
174- sendJson ( res , { success : true , userProfile } ) ;
188+ // Token rotation: if the client supports it, issue a new short-lived JWT and replace the old one.
189+ const supportsRotation = req . get ( HTTP . HEADERS . X_SUPPORTS_TOKEN_ROTATION ) === '1' ;
190+ let rotatedAccessToken : string | undefined ;
191+ if ( supportsRotation && deviceId ) {
192+ const oldAccessToken = req . get ( 'Authorization' ) ?. split ( ' ' ) [ 1 ] ;
193+ if ( oldAccessToken ) {
194+ rotatedAccessToken = await externalAuthService . rotateToken ( {
195+ userProfile,
196+ audience : externalAuthService . AUDIENCE_WEB_EXT ,
197+ source : webExtDb . TOKEN_SOURCE_BROWSER_EXTENSION ,
198+ deviceId,
199+ oldAccessToken,
200+ ipAddress : res . locals . ipAddress || getApiAddressFromReq ( req ) ,
201+ userAgent : req . get ( 'User-Agent' ) || 'unknown' ,
202+ } ) ;
203+ if ( rotatedAccessToken ) {
204+ res . log . info ( { userId : userProfile . id , deviceId } , 'Web extension token verified and rotated' ) ;
205+ } else {
206+ res . log . info ( { userId : userProfile . id , deviceId } , 'Web extension token verified (rotation skipped — concurrent race)' ) ;
207+ }
208+ }
209+ }
210+
211+ if ( ! supportsRotation ) {
212+ res . log . info ( { userId : userProfile . id , deviceId } , 'Web extension token verified' ) ;
213+ }
214+
215+ sendJson ( res , { success : true , userProfile, accessToken : rotatedAccessToken } ) ;
175216 } catch ( ex ) {
176217 res . log . error ( { userId : user ?. id , deviceId, ...getErrorMessageAndStackObj ( ex ) } , 'Error verifying web extension token' ) ;
177218 sendJson ( res , { success : false , error : 'Invalid session' } , 401 ) ;
178219 }
179220} ) ;
180221
181- const logout = createRoute ( routeDefinition . logout . validators , async ( { user } , _ , res ) => {
222+ const logout = createRoute ( routeDefinition . logout . validators , async ( { user } , req , res ) => {
182223 const { deviceId } = res . locals ;
183224 try {
184225 if ( ! deviceId || ! user ) {
185226 throw new InvalidSession ( ) ;
186227 }
187228 // This validates the token against the database record
188229 await webExtDb . deleteByUserIdAndDeviceId ( { userId : user . id , deviceId, type : webExtDb . TOKEN_TYPE_AUTH } ) ;
230+ // Invalidate the LRU cache so the token is rejected immediately rather than serving from cache
231+ // Check both Authorization header and body for legacy clients that send accessToken in the body
232+ const accessToken = req . get ( 'Authorization' ) ?. split ( ' ' ) [ 1 ] || ( req . body as { accessToken ?: string } | undefined ) ?. accessToken ;
233+ if ( accessToken ) {
234+ externalAuthService . invalidateCacheEntry ( accessToken , deviceId ) ;
235+ }
189236 res . log . info ( { userId : user . id , deviceId } , 'User logged out of browser extension' ) ;
190237
191238 sendJson ( res , { success : true } ) ;
0 commit comments