From d04b6a0b787eaa6b1f972ee6131410f86c612701 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Tue, 31 Mar 2026 20:25:32 -0700 Subject: [PATCH] feat: implement JWT token rotation for desktop and web extension authentication --- .../app/controllers/desktop-app.controller.ts | 50 ++++- .../controllers/web-extension.controller.ts | 59 +++++- apps/api/src/app/db/web-extension.db.ts | 42 ++++ .../src/app/services/external-auth.service.ts | 77 ++++++- apps/api/src/main.ts | 2 + .../src/services/api.service.ts | 2 + .../src/services/ipc.service.ts | 29 ++- .../external-auth-logged-in.spec.ts | 200 ++++++++++++++++++ .../src/extension-scripts/service-worker.ts | 19 +- .../src/utils/extension.types.ts | 2 +- .../constants/src/lib/shared-constants.ts | 1 + 11 files changed, 455 insertions(+), 28 deletions(-) diff --git a/apps/api/src/app/controllers/desktop-app.controller.ts b/apps/api/src/app/controllers/desktop-app.controller.ts index b26a70ea4..32dbf8714 100644 --- a/apps/api/src/app/controllers/desktop-app.controller.ts +++ b/apps/api/src/app/controllers/desktop-app.controller.ts @@ -62,6 +62,7 @@ export const routeDefinition = { success: z.literal(true), userProfile: UserProfileUiSchema, encryptionKey: z.string(), + accessToken: z.string().optional(), }), z.object({ success: z.literal(false), @@ -141,7 +142,7 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id, omitSubscriptions: true }); - // Check for existing valid token with refresh buffer (7 days) + // Check for existing valid token with refresh buffer (TOKEN_AUTO_REFRESH_DAYS) const existingTokenRecord = await webExtDb.findByUserIdAndDeviceId({ userId: user.id, deviceId, @@ -166,7 +167,11 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ } // Issue new token if none exists or about to expire - const accessToken = await externalAuthService.issueAccessToken(userProfile, externalAuthService.AUDIENCE_DESKTOP); + const accessToken = await externalAuthService.issueAccessToken( + userProfile, + externalAuthService.AUDIENCE_DESKTOP, + externalAuthService.TOKEN_EXPIRATION_SHORT, + ); await webExtDb.create(user.id, { type: webExtDb.TOKEN_TYPE_AUTH, source: webExtDb.TOKEN_SOURCE_DESKTOP, @@ -187,7 +192,7 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ }); }); -const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, _, res) => { +const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, req, res) => { const { deviceId } = res.locals; try { if (!user) { @@ -195,26 +200,59 @@ const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ } const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id, omitSubscriptions: true }); - res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified'); // Derive a per-user portable encryption key for local org data encryption on the desktop app. // The key is the same on any machine the user logs into; org data never leaves the device. const encryptionKey = createHmac('sha256', ENV.DESKTOP_ORG_ENCRYPTION_SECRET).update(user.id).digest('hex'); - sendJson(res, { success: true, userProfile, encryptionKey }); + // Token rotation: if the client supports it, issue a new short-lived JWT and replace the old one. + // This limits exposure from the JWT being stored in plain text on disk (VDI environments). + const supportsRotation = req.get(HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION) === '1'; + let rotatedAccessToken: string | undefined; + if (supportsRotation && deviceId) { + const oldAccessToken = req.get('Authorization')?.split(' ')[1]; + if (oldAccessToken) { + rotatedAccessToken = await externalAuthService.rotateToken({ + userProfile, + audience: externalAuthService.AUDIENCE_DESKTOP, + source: webExtDb.TOKEN_SOURCE_DESKTOP, + deviceId, + oldAccessToken, + ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), + userAgent: req.get('User-Agent') || 'unknown', + }); + if (rotatedAccessToken) { + res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified and rotated'); + } else { + res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified (rotation skipped — concurrent race)'); + } + } + } + + if (!supportsRotation) { + res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified'); + } + + sendJson(res, { success: true, userProfile, encryptionKey, accessToken: rotatedAccessToken }); } catch (ex) { res.log.error({ userId: user?.id, deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error verifying Desktop App token'); sendJson(res, { success: false, error: 'Invalid session' }, 401); } }); -const logout = createRoute(routeDefinition.logout.validators, async ({ user }, _, res) => { +const logout = createRoute(routeDefinition.logout.validators, async ({ user }, req, res) => { const { deviceId } = res.locals; try { if (!deviceId || !user) { throw new InvalidSession(); } await webExtDb.deleteByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH }); + // Invalidate the LRU cache so the token is rejected immediately rather than serving from cache + // Check both Authorization header and body for legacy clients that send accessToken in the body + const accessToken = req.get('Authorization')?.split(' ')[1] || (req.body as { accessToken?: string } | undefined)?.accessToken; + if (accessToken) { + externalAuthService.invalidateCacheEntry(accessToken, deviceId); + } res.log.info({ userId: user.id, deviceId }, 'User logged out of desktop app'); sendJson(res, { success: true }); diff --git a/apps/api/src/app/controllers/web-extension.controller.ts b/apps/api/src/app/controllers/web-extension.controller.ts index e98c06e8a..69b09b811 100644 --- a/apps/api/src/app/controllers/web-extension.controller.ts +++ b/apps/api/src/app/controllers/web-extension.controller.ts @@ -9,6 +9,7 @@ import { import { HTTP } from '@jetstream/shared/constants'; import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; import { fromUnixTime } from 'date-fns'; +import { UserProfileUiSchema } from '@jetstream/types'; import { z } from 'zod'; import { routeDefinition as dataSyncController } from '../controllers/data-sync.controller'; import * as userSyncDbService from '../db/data-sync.db'; @@ -55,7 +56,17 @@ export const routeDefinition = { }, verifyToken: { controllerFn: () => verifyToken, - responseType: z.object({ success: z.boolean(), error: z.string().nullish() }), + responseType: z.discriminatedUnion('success', [ + z.object({ + success: z.literal(true), + userProfile: UserProfileUiSchema, + accessToken: z.string().optional(), + }), + z.object({ + success: z.literal(false), + error: z.string().nullish(), + }), + ]), validators: { /** * @deprecated, prefer headers for passing deviceId and accessToken @@ -143,7 +154,11 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ } // Issue new token if none exists or about to expire - const accessToken = await externalAuthService.issueAccessToken(userProfile, externalAuthService.AUDIENCE_WEB_EXT); + const accessToken = await externalAuthService.issueAccessToken( + userProfile, + externalAuthService.AUDIENCE_WEB_EXT, + externalAuthService.TOKEN_EXPIRATION_SHORT, + ); await webExtDb.create(user.id, { type: webExtDb.TOKEN_TYPE_AUTH, source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION, @@ -162,23 +177,49 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ }); }); -const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, _, res) => { +const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, req, res) => { const { deviceId } = res.locals; try { if (!user) { throw new InvalidSession(); } const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id, omitSubscriptions: true }); - res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified'); - sendJson(res, { success: true, userProfile }); + // Token rotation: if the client supports it, issue a new short-lived JWT and replace the old one. + const supportsRotation = req.get(HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION) === '1'; + let rotatedAccessToken: string | undefined; + if (supportsRotation && deviceId) { + const oldAccessToken = req.get('Authorization')?.split(' ')[1]; + if (oldAccessToken) { + rotatedAccessToken = await externalAuthService.rotateToken({ + userProfile, + audience: externalAuthService.AUDIENCE_WEB_EXT, + source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION, + deviceId, + oldAccessToken, + ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), + userAgent: req.get('User-Agent') || 'unknown', + }); + if (rotatedAccessToken) { + res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified and rotated'); + } else { + res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified (rotation skipped — concurrent race)'); + } + } + } + + if (!supportsRotation) { + res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified'); + } + + sendJson(res, { success: true, userProfile, accessToken: rotatedAccessToken }); } catch (ex) { res.log.error({ userId: user?.id, deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error verifying web extension token'); sendJson(res, { success: false, error: 'Invalid session' }, 401); } }); -const logout = createRoute(routeDefinition.logout.validators, async ({ user }, _, res) => { +const logout = createRoute(routeDefinition.logout.validators, async ({ user }, req, res) => { const { deviceId } = res.locals; try { if (!deviceId || !user) { @@ -186,6 +227,12 @@ const logout = createRoute(routeDefinition.logout.validators, async ({ user }, _ } // This validates the token against the database record await webExtDb.deleteByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH }); + // Invalidate the LRU cache so the token is rejected immediately rather than serving from cache + // Check both Authorization header and body for legacy clients that send accessToken in the body + const accessToken = req.get('Authorization')?.split(' ')[1] || (req.body as { accessToken?: string } | undefined)?.accessToken; + if (accessToken) { + externalAuthService.invalidateCacheEntry(accessToken, deviceId); + } res.log.info({ userId: user.id, deviceId }, 'User logged out of browser extension'); sendJson(res, { success: true }); diff --git a/apps/api/src/app/db/web-extension.db.ts b/apps/api/src/app/db/web-extension.db.ts index 018cfea4d..ef1d4aed1 100644 --- a/apps/api/src/app/db/web-extension.db.ts +++ b/apps/api/src/app/db/web-extension.db.ts @@ -141,6 +141,48 @@ export const create = async ( }); }; +/** + * Conditionally replace a token only if the current tokenHash matches oldTokenHash. + * Prevents a race where two concurrent rotation requests both replace the same token, + * leaving one client with an invalid token. + * Returns true if the token was replaced, false if it was already rotated by another request. + */ +export const replaceTokenIfCurrent = async ( + userId: string, + oldTokenHash: string, + payload: { + type: TokenType; + source: TokenSource; + token: string; + deviceId: string; + ipAddress: string; + userAgent: string; + expiresAt: Date; + }, +): Promise => { + const token = encryptJwtToken(payload.token); + const tokenHash = hashToken(payload.token); + + const result = await prisma.webExtensionToken.updateMany({ + where: { + type: payload.type, + userId, + deviceId: payload.deviceId, + tokenHash: oldTokenHash, + }, + data: { + token, + tokenHash, + source: payload.source, + ipAddress: payload.ipAddress, + userAgent: payload.userAgent, + expiresAt: payload.expiresAt, + }, + }); + + return result.count > 0; +}; + export const deleteByUserIdAndDeviceId = async ({ userId, deviceId, type }: { userId: string; deviceId: string; type: TokenType }) => { await prisma.webExtensionToken.deleteMany({ where: { type, userId, deviceId }, diff --git a/apps/api/src/app/services/external-auth.service.ts b/apps/api/src/app/services/external-auth.service.ts index 13d22383f..06aaeaa2b 100644 --- a/apps/api/src/app/services/external-auth.service.ts +++ b/apps/api/src/app/services/external-auth.service.ts @@ -1,13 +1,16 @@ -import { ENV } from '@jetstream/api-config'; +import { ENV, logger } from '@jetstream/api-config'; import { convertUserProfileToSession_External, InvalidAccessToken } from '@jetstream/auth/server'; -import { UserProfileSession } from '@jetstream/auth/types'; +import { TokenSource, UserProfileSession } from '@jetstream/auth/types'; import { HTTP } from '@jetstream/shared/constants'; import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; import { Maybe, UserProfileUi } from '@jetstream/types'; +import { randomUUID } from 'crypto'; +import { fromUnixTime } from 'date-fns'; import * as express from 'express'; import jwt from 'fast-jwt'; import { LRUCache } from 'lru-cache'; import * as webExtDb from '../db/web-extension.db'; +import { hashToken } from '../services/jwt-token-encryption.service'; import { AuthenticationError } from '../utils/error-handler'; const cache = new LRUCache({ max: 500 }); @@ -16,8 +19,9 @@ export const AUDIENCE_WEB_EXT = 'https://getjetstream.app/web-extension'; export const AUDIENCE_DESKTOP = 'https://getjetstream.app/desktop-app'; const ISSUER = 'https://getjetstream.app'; -export const TOKEN_AUTO_REFRESH_DAYS = 7; +export const TOKEN_AUTO_REFRESH_DAYS = 2; const TOKEN_EXPIRATION = 60 * 60 * 24 * 90 * 1000; // 90 days +export const TOKEN_EXPIRATION_SHORT = 60 * 60 * 24 * 7 * 1000; // 7 days export type Audience = typeof AUDIENCE_WEB_EXT | typeof AUDIENCE_DESKTOP; @@ -30,7 +34,7 @@ export interface JwtDecodedPayload { exp: number; } -function prepareJwtFns(userId: string, durationMs, audience) { +function prepareJwtFns(userId: string, durationMs: number, audience: string) { const jwtSigner = jwt.createSigner({ key: async () => ENV.JETSTREAM_AUTH_WEB_EXT_JWT_SECRET, algorithm: 'HS256', @@ -54,12 +58,69 @@ function prepareJwtFns(userId: string, durationMs, audience) { async function generateJwt({ payload, durationMs }: { payload: UserProfileUi; durationMs: number }, audience: Audience) { const { jwtSigner } = prepareJwtFns(payload.id, durationMs, audience); - const token = await jwtSigner({ userProfile: payload }); + const token = await jwtSigner({ userProfile: payload, jti: randomUUID() }); return token; } -export async function issueAccessToken(payload: UserProfileUi, audience: Audience) { - return await generateJwt({ payload, durationMs: TOKEN_EXPIRATION }, audience); +export async function issueAccessToken(payload: UserProfileUi, audience: Audience, durationMs?: number) { + return await generateJwt({ payload, durationMs: durationMs ?? TOKEN_EXPIRATION }, audience); +} + +export function invalidateCacheEntry(accessToken: string, deviceId: string): void { + const cacheKey = `${accessToken}-${deviceId}`; + cache.delete(cacheKey); +} + +/** + * Issue a new short-lived JWT, replace the old token in the DB, and invalidate the LRU cache. + * Used by both desktop and web extension controllers during /auth/verify when the client + * sends the X-Supports-Token-Rotation header. + * + * Uses a conditional update (checking the old tokenHash) to prevent a race where two + * concurrent requests both rotate the same token — the second attempt returns undefined + * instead of silently overwriting the first rotation's token. + */ +export async function rotateToken({ + userProfile, + audience, + source, + deviceId, + oldAccessToken, + ipAddress, + userAgent, + durationMs, +}: { + userProfile: UserProfileUi; + audience: Audience; + source: TokenSource; + deviceId: string; + oldAccessToken: string; + ipAddress: string; + userAgent: string; + durationMs?: number; +}): Promise { + const newAccessToken = await issueAccessToken(userProfile, audience, durationMs ?? TOKEN_EXPIRATION_SHORT); + const oldTokenHash = hashToken(oldAccessToken); + const wasReplaced = await webExtDb.replaceTokenIfCurrent(userProfile.id, oldTokenHash, { + type: webExtDb.TOKEN_TYPE_AUTH, + source, + token: newAccessToken, + deviceId, + ipAddress, + userAgent, + expiresAt: fromUnixTime(decodeToken(newAccessToken).exp), + }); + // Always invalidate the old token from cache — whether we won or lost the race, + // the old token hash is no longer current in the DB and should not be served from cache. + invalidateCacheEntry(oldAccessToken, deviceId); + if (!wasReplaced) { + // Another concurrent request already rotated this token — skip to avoid invalidating the winner's token. + // Note: if the rotation response is lost (network failure), the client will hold a stale token and must re-login. + // This is an accepted trade-off to avoid the complexity of dual-token grace periods. + logger.warn({ userId: userProfile.id, deviceId, audience }, 'rotateToken: race lost — token already rotated by another request'); + return undefined; + } + return newAccessToken; } export function decodeToken(token: string): JwtDecodedPayload { @@ -154,7 +215,7 @@ export function getExternalAuthMiddleware(audience: Audience) { res.locals.deviceId = deviceId; next(); } catch (ex) { - req.log.info('[DESKTOP-AUTH][AUTH ERROR] Error decoding token', ex); + req.log.info('[EXTERNAL AUTH ERROR] Error decoding token', ex); next(new AuthenticationError('Unauthorized', { skipLogout: true })); } }; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 66f41bfd4..e053d77f3 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -225,6 +225,8 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) { HTTP.HEADERS.AUTHORIZATION, HTTP.HEADERS.X_EXT_DEVICE_ID, HTTP.HEADERS.X_WEB_EXTENSION_DEVICE_ID, + HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION, + HTTP.HEADERS.X_APP_VERSION, ].join(', '); app.use('/web-extension/*splat', (req: express.Request, res: express.Response, next: express.NextFunction) => { if ( diff --git a/apps/jetstream-desktop/src/services/api.service.ts b/apps/jetstream-desktop/src/services/api.service.ts index 813fc48d1..ddf7d24c0 100644 --- a/apps/jetstream-desktop/src/services/api.service.ts +++ b/apps/jetstream-desktop/src/services/api.service.ts @@ -10,6 +10,7 @@ const AuthResponseSuccessSchema = z.object({ success: z.literal(true), userProfile: UserProfileUiSchema, encryptionKey: z.string().length(64), + accessToken: z.string().optional(), }); const AuthResponseErrorSchema = z.object({ success: z.literal(false), error: z.string() }); const SuccessOrErrorSchema = z.union([AuthResponseSuccessSchema, AuthResponseErrorSchema]); @@ -26,6 +27,7 @@ export async function verifyAuthToken({ accessToken, deviceId }: { deviceId: str Authorization: `Bearer ${accessToken}`, [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1', }, }); diff --git a/apps/jetstream-desktop/src/services/ipc.service.ts b/apps/jetstream-desktop/src/services/ipc.service.ts index 1f208087c..280155b1e 100644 --- a/apps/jetstream-desktop/src/services/ipc.service.ts +++ b/apps/jetstream-desktop/src/services/ipc.service.ts @@ -14,9 +14,10 @@ import { ApiConnection, getApiRequestFactoryFn, getBinaryFileRecordQueryMap } fr import * as oauthService from '@jetstream/salesforce-oauth'; import { HTTP } from '@jetstream/shared/constants'; import { JetstreamEventStreamFilePayload, UserProfileUi } from '@jetstream/types'; -import { addHours } from 'date-fns'; +import { addHours, fromUnixTime } from 'date-fns'; import { app, dialog, ipcMain, shell } from 'electron'; import logger from 'electron-log'; +import { jwtDecode } from 'jwt-decode'; import { ResponseBodyError } from 'oauth4webapi'; import { Method } from 'tiny-request-router'; import { z } from 'zod'; @@ -127,9 +128,11 @@ const handleLoginEvent: MainIpcHandler<'login'> = async (event) => { if (response.success) { const successResponse = response as AuthResponseSuccess; + // Use the rotated token if the server provided one, otherwise keep the original token + const activeAccessToken = successResponse.accessToken || accessToken; const { userProfile } = dataService.saveAuthResponseToAppData({ deviceId, - accessToken, + accessToken: activeAccessToken, userProfile: successResponse.userProfile, }); @@ -140,7 +143,7 @@ const handleLoginEvent: MainIpcHandler<'login'> = async (event) => { const payload: AuthenticateSuccessPayload = { // eslint-disable-next-line @typescript-eslint/no-explicit-any userProfile: userProfile as any, - authInfo: { deviceId, accessToken }, + authInfo: { deviceId, accessToken: activeAccessToken }, success: true, }; event.sender.send(IpcEventChannel.authenticate, payload); @@ -367,7 +370,6 @@ const handleCheckAuthEvent: MainIpcHandler<'checkAuth'> = async (): Promise< const userProfile = dataService.getFullUserProfile(); const { deviceId, accessToken, lastChecked } = appData; if (accessToken && userProfile) { - // TODO: implement a refresh token flow if ( !lastChecked || lastChecked < addHours(new Date(), -AUTH_CHECK_INTERVAL_HOURS).getTime() || @@ -386,15 +388,32 @@ const handleCheckAuthEvent: MainIpcHandler<'checkAuth'> = async (): Promise< return; } const successResponse = response as AuthResponseSuccess; - logger.info('Authentication check successful'); + // Use the rotated token if the server provided one, otherwise keep the current token + const activeAccessToken = successResponse.accessToken || accessToken; + logger.info('Authentication check successful', successResponse.accessToken ? '(token rotated)' : ''); + // If the token was rotated, decode the new expiry from the JWT + let expiresAt = appData.expiresAt; + if (successResponse.accessToken) { + try { + const decoded = jwtDecode<{ exp?: number }>(successResponse.accessToken); + if (typeof decoded.exp === 'number' && Number.isFinite(decoded.exp)) { + expiresAt = fromUnixTime(decoded.exp).getTime(); + } + } catch { + // If decode fails, keep the old expiresAt + } + } dataService.setAppData({ ...appData, + accessToken: activeAccessToken, userProfile: successResponse.userProfile, + expiresAt, lastChecked: Date.now(), }); if (successResponse.encryptionKey) { dataService.setOrgEncryptionKey(successResponse.encryptionKey); } + return { userProfile: successResponse.userProfile, authInfo: { deviceId, accessToken: activeAccessToken } }; } return { userProfile, authInfo: { deviceId, accessToken } }; diff --git a/apps/jetstream-e2e/src/tests/authentication/external-auth/external-auth-logged-in.spec.ts b/apps/jetstream-e2e/src/tests/authentication/external-auth/external-auth-logged-in.spec.ts index 928dfa42e..bd7dfb3c0 100644 --- a/apps/jetstream-e2e/src/tests/authentication/external-auth/external-auth-logged-in.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/external-auth/external-auth-logged-in.spec.ts @@ -148,6 +148,206 @@ test.describe('Desktop / Web-Extension Authentication', () => { }); }); +test.describe('Desktop / Web-Extension Token Rotation', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page, authenticationPage }) => { + await page.goto('/'); + await authenticationPage.acceptCookieBanner(); + }); + + test('Desktop token rotation - rotated token works, old token is invalidated', async ({ + page, + teamCreationUtils1User, + apiRequestUtils, + }) => { + const deviceId = uuid(); + + // 1. Create session and get original token + const sessionResponse = await apiRequestUtils.request.post(`/desktop-app/auth/session`, { + headers: { [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId }, + }); + expect(sessionResponse.status()).toBe(200); + const { accessToken: originalToken } = await sessionResponse.json().then(({ data }) => data); + expect(typeof originalToken).toBe('string'); + + // 2. Verify with rotation header — should get a new token + const rotateResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${originalToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1', + }, + }); + expect(rotateResponse.status()).toBe(200); + const rotateData = await rotateResponse.json().then(({ data }) => data); + expect(rotateData.success).toBe(true); + expect(rotateData.accessToken).toBeDefined(); + expect(rotateData.accessToken).not.toBe(originalToken); + const rotatedToken = rotateData.accessToken; + + // 3. Rotated token works for subsequent verify + const verifyWithRotated = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${rotatedToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1', + }, + }); + expect(verifyWithRotated.status()).toBe(200); + + // 4. Original token is now invalid (its hash was replaced in the DB) + const verifyWithOriginal = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${originalToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); + expect(verifyWithOriginal.status()).toBe(401); + }); + + test('Web extension token rotation - rotated token works, old token is invalidated', async ({ + page, + teamCreationUtils1User, + apiRequestUtils, + }) => { + const deviceId = uuid(); + + // 1. Create session and get original token + const sessionResponse = await apiRequestUtils.request.post(`/web-extension/auth/session`, { + headers: { [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId }, + }); + expect(sessionResponse.status()).toBe(200); + const { accessToken: originalToken } = await sessionResponse.json().then(({ data }) => data); + expect(typeof originalToken).toBe('string'); + + // 2. Verify with rotation header — should get a new token + const rotateResponse = await apiRequestUtils.request.post(`/web-extension/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${originalToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1', + }, + }); + expect(rotateResponse.status()).toBe(200); + const rotateData = await rotateResponse.json().then(({ data }) => data); + expect(rotateData.success).toBe(true); + expect(rotateData.accessToken).toBeDefined(); + expect(rotateData.accessToken).not.toBe(originalToken); + const rotatedToken = rotateData.accessToken; + + // 3. Rotated token works + const verifyWithRotated = await apiRequestUtils.request.post(`/web-extension/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${rotatedToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1', + }, + }); + expect(verifyWithRotated.status()).toBe(200); + + // 4. Original token is now invalid + const verifyWithOriginal = await apiRequestUtils.request.post(`/web-extension/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${originalToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); + expect(verifyWithOriginal.status()).toBe(401); + }); + + test('No rotation without header (backward compat)', async ({ page, teamCreationUtils1User, apiRequestUtils }) => { + const deviceId = uuid(); + + // 1. Create session + const sessionResponse = await apiRequestUtils.request.post(`/desktop-app/auth/session`, { + headers: { [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId }, + }); + expect(sessionResponse.status()).toBe(200); + const { accessToken } = await sessionResponse.json().then(({ data }) => data); + + // 2. Verify WITHOUT rotation header — should not include accessToken in response + const verifyResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); + expect(verifyResponse.status()).toBe(200); + const verifyData = await verifyResponse.json().then(({ data }) => data); + expect(verifyData.success).toBe(true); + expect(verifyData.accessToken).toBeUndefined(); + + // 3. Same token still works (was not rotated) + const verifyAgain = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); + expect(verifyAgain.status()).toBe(200); + }); + + test('Logout invalidates rotated token', async ({ page, teamCreationUtils1User, apiRequestUtils }) => { + const deviceId = uuid(); + + // 1. Create session + const sessionResponse = await apiRequestUtils.request.post(`/desktop-app/auth/session`, { + headers: { [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId }, + }); + const { accessToken: originalToken } = await sessionResponse.json().then(({ data }) => data); + + // 2. Rotate the token + const rotateResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${originalToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1', + }, + }); + const { accessToken: rotatedToken } = await rotateResponse.json().then(({ data }) => data); + + // 3. Logout with the rotated token + const logoutResponse = await apiRequestUtils.request.delete(`/desktop-app/auth/logout`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${rotatedToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); + expect(logoutResponse.status()).toBe(200); + + // 4. Rotated token is now invalid + const verifyAfterLogout = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${rotatedToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); + expect(verifyAfterLogout.status()).toBe(401); + }); +}); + test.describe('Desktop / Web-Extension Authentication - Not Logged In', () => { test.use({ storageState: { cookies: [], origins: [] } }); test('Desktop Authentication Redirected to Login', async ({ page }) => { diff --git a/apps/jetstream-web-extension/src/extension-scripts/service-worker.ts b/apps/jetstream-web-extension/src/extension-scripts/service-worker.ts index af6874dc2..9fb80edd8 100644 --- a/apps/jetstream-web-extension/src/extension-scripts/service-worker.ts +++ b/apps/jetstream-web-extension/src/extension-scripts/service-worker.ts @@ -466,7 +466,7 @@ async function handleVerifyAuth(sender: browser.Runtime.MessageSender): Promise< return { hasTokens: true, loggedIn: true }; } - const results: { success: true } | { success: false; error: string } = await fetch( + const results: { success: true; accessToken?: string } | { success: false; error: string } = await fetch( `${environment.serverUrl}/web-extension/auth/verify`, { method: 'POST', @@ -475,6 +475,7 @@ async function handleVerifyAuth(sender: browser.Runtime.MessageSender): Promise< Authorization: `Bearer ${authTokens.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, [HTTP.HEADERS.X_APP_VERSION]: browser.runtime.getManifest().version, + [HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1', }, }, ).then((res) => @@ -492,7 +493,21 @@ async function handleVerifyAuth(sender: browser.Runtime.MessageSender): Promise< storageSyncCache.authTokens = undefined; return { hasTokens: true, loggedIn: false, error: results.error }; } - const syncState = { ...authTokens, loggedIn: true, lastChecked: Date.now() }; + // Use the rotated token if the server provided one, otherwise keep the current token + const activeAccessToken = results.accessToken || authTokens.accessToken; + // If the token was rotated, decode the new expiry from the JWT payload + let expiresAt = authTokens.expiresAt; + if (results.accessToken) { + try { + const payload = jwtDecode<{ exp: number }>(results.accessToken); + if (isNumber(payload.exp) && Number.isFinite(payload.exp)) { + expiresAt = payload.exp * 1000; + } + } catch { + // If decode fails, keep the old expiresAt + } + } + const syncState = { ...authTokens, accessToken: activeAccessToken, expiresAt, loggedIn: true, lastChecked: Date.now() }; await browser.storage.sync.set({ [storageTypes.authTokens.key]: syncState }); storageSyncCache.authTokens = syncState; return { hasTokens: true, loggedIn: true }; diff --git a/apps/jetstream-web-extension/src/utils/extension.types.ts b/apps/jetstream-web-extension/src/utils/extension.types.ts index 29baefaa7..57a28bbd2 100644 --- a/apps/jetstream-web-extension/src/utils/extension.types.ts +++ b/apps/jetstream-web-extension/src/utils/extension.types.ts @@ -2,7 +2,7 @@ import { ApiConnection } from '@jetstream/salesforce-api'; import type { Maybe, SalesforceOrgUi, SoqlQueryFormatOptions, UserProfileUi } from '@jetstream/types'; import { z } from 'zod'; -export const AUTH_CHECK_INTERVAL_MIN = 5; +export const AUTH_CHECK_INTERVAL_MIN = 180; export const DEFAULT_BUTTON_POSITION: ButtonPosition = { location: 'right', diff --git a/libs/shared/constants/src/lib/shared-constants.ts b/libs/shared/constants/src/lib/shared-constants.ts index 65248db3b..1f1be9f7e 100644 --- a/libs/shared/constants/src/lib/shared-constants.ts +++ b/libs/shared/constants/src/lib/shared-constants.ts @@ -65,6 +65,7 @@ export const HTTP = { X_WEB_EXTENSION_DEVICE_ID: 'X-Web-Extension-Device-Identifier', X_EXT_DEVICE_ID: 'X-Ext-Id', X_APP_VERSION: 'X-App-Version', + X_SUPPORTS_TOKEN_ROTATION: 'X-Supports-Token-Rotation', CONTENT_TYPE: 'Content-Type', X_MOCK_KEY: 'X-MOCK-KEY', X_FORWARDED_FOR: 'X-FORWARDED-FOR',