Skip to content

Commit 1d0c78f

Browse files
committed
feat: implement JWT token rotation for desktop and web extension authentication
1 parent 2b5a978 commit 1d0c78f

11 files changed

Lines changed: 446 additions & 24 deletions

File tree

apps/api/src/app/controllers/desktop-app.controller.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const routeDefinition = {
6262
success: z.literal(true),
6363
userProfile: UserProfileUiSchema,
6464
encryptionKey: z.string(),
65+
accessToken: z.string().optional(),
6566
}),
6667
z.object({
6768
success: z.literal(false),
@@ -166,7 +167,11 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({
166167
}
167168

168169
// Issue new token if none exists or about to expire
169-
const accessToken = await externalAuthService.issueAccessToken(userProfile, externalAuthService.AUDIENCE_DESKTOP);
170+
const accessToken = await externalAuthService.issueAccessToken(
171+
userProfile,
172+
externalAuthService.AUDIENCE_DESKTOP,
173+
externalAuthService.TOKEN_EXPIRATION_SHORT,
174+
);
170175
await webExtDb.create(user.id, {
171176
type: webExtDb.TOKEN_TYPE_AUTH,
172177
source: webExtDb.TOKEN_SOURCE_DESKTOP,
@@ -187,34 +192,67 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({
187192
});
188193
});
189194

190-
const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, _, res) => {
195+
const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, req, res) => {
191196
const { deviceId } = res.locals;
192197
try {
193198
if (!user) {
194199
throw new InvalidSession();
195200
}
196201

197202
const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id, omitSubscriptions: true });
198-
res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified');
199203

200204
// Derive a per-user portable encryption key for local org data encryption on the desktop app.
201205
// The key is the same on any machine the user logs into; org data never leaves the device.
202206
const encryptionKey = createHmac('sha256', ENV.DESKTOP_ORG_ENCRYPTION_SECRET).update(user.id).digest('hex');
203207

204-
sendJson(res, { success: true, userProfile, encryptionKey });
208+
// Token rotation: if the client supports it, issue a new short-lived JWT and replace the old one.
209+
// This limits exposure from the JWT being stored in plain text on disk (VDI environments).
210+
const supportsRotation = req.get(HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION) === '1';
211+
let rotatedAccessToken: string | undefined;
212+
if (supportsRotation && deviceId) {
213+
const oldAccessToken = req.get('Authorization')?.split(' ')[1];
214+
if (oldAccessToken) {
215+
rotatedAccessToken = await externalAuthService.rotateToken({
216+
userProfile,
217+
audience: externalAuthService.AUDIENCE_DESKTOP,
218+
source: webExtDb.TOKEN_SOURCE_DESKTOP,
219+
deviceId,
220+
oldAccessToken,
221+
ipAddress: res.locals.ipAddress || getApiAddressFromReq(req),
222+
userAgent: req.get('User-Agent') || 'unknown',
223+
});
224+
if (rotatedAccessToken) {
225+
res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified and rotated');
226+
} else {
227+
res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified (rotation skipped — concurrent race)');
228+
}
229+
}
230+
}
231+
232+
if (!supportsRotation) {
233+
res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified');
234+
}
235+
236+
sendJson(res, { success: true, userProfile, encryptionKey, accessToken: rotatedAccessToken });
205237
} catch (ex) {
206238
res.log.error({ userId: user?.id, deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error verifying Desktop App token');
207239
sendJson(res, { success: false, error: 'Invalid session' }, 401);
208240
}
209241
});
210242

211-
const logout = createRoute(routeDefinition.logout.validators, async ({ user }, _, res) => {
243+
const logout = createRoute(routeDefinition.logout.validators, async ({ user }, req, res) => {
212244
const { deviceId } = res.locals;
213245
try {
214246
if (!deviceId || !user) {
215247
throw new InvalidSession();
216248
}
217249
await webExtDb.deleteByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH });
250+
// Invalidate the LRU cache so the token is rejected immediately rather than serving from cache
251+
// Check both Authorization header and body for legacy clients that send accessToken in the body
252+
const accessToken = req.get('Authorization')?.split(' ')[1] || (req.body as { accessToken?: string } | undefined)?.accessToken;
253+
if (accessToken) {
254+
externalAuthService.invalidateCacheEntry(accessToken, deviceId);
255+
}
218256
res.log.info({ userId: user.id, deviceId }, 'User logged out of desktop app');
219257

220258
sendJson(res, { success: true });

apps/api/src/app/controllers/web-extension.controller.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { HTTP } from '@jetstream/shared/constants';
1010
import { getErrorMessageAndStackObj } from '@jetstream/shared/utils';
1111
import { fromUnixTime } from 'date-fns';
12+
import { UserProfileUiSchema } from '@jetstream/types';
1213
import { z } from 'zod';
1314
import { routeDefinition as dataSyncController } from '../controllers/data-sync.controller';
1415
import * 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 });

apps/api/src/app/db/web-extension.db.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,48 @@ export const create = async (
141141
});
142142
};
143143

144+
/**
145+
* Conditionally replace a token only if the current tokenHash matches oldTokenHash.
146+
* Prevents a race where two concurrent rotation requests both replace the same token,
147+
* leaving one client with an invalid token.
148+
* Returns true if the token was replaced, false if it was already rotated by another request.
149+
*/
150+
export const replaceTokenIfCurrent = async (
151+
userId: string,
152+
oldTokenHash: string,
153+
payload: {
154+
type: TokenType;
155+
source: TokenSource;
156+
token: string;
157+
deviceId: string;
158+
ipAddress: string;
159+
userAgent: string;
160+
expiresAt: Date;
161+
},
162+
): Promise<boolean> => {
163+
const token = encryptJwtToken(payload.token);
164+
const tokenHash = hashToken(payload.token);
165+
166+
const result = await prisma.webExtensionToken.updateMany({
167+
where: {
168+
type: payload.type,
169+
userId,
170+
deviceId: payload.deviceId,
171+
tokenHash: oldTokenHash,
172+
},
173+
data: {
174+
token,
175+
tokenHash,
176+
source: payload.source,
177+
ipAddress: payload.ipAddress,
178+
userAgent: payload.userAgent,
179+
expiresAt: payload.expiresAt,
180+
},
181+
});
182+
183+
return result.count > 0;
184+
};
185+
144186
export const deleteByUserIdAndDeviceId = async ({ userId, deviceId, type }: { userId: string; deviceId: string; type: TokenType }) => {
145187
await prisma.webExtensionToken.deleteMany({
146188
where: { type, userId, deviceId },

apps/api/src/app/services/external-auth.service.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import { ENV } from '@jetstream/api-config';
1+
import { ENV, logger } from '@jetstream/api-config';
22
import { convertUserProfileToSession_External, InvalidAccessToken } from '@jetstream/auth/server';
3-
import { UserProfileSession } from '@jetstream/auth/types';
3+
import { TokenSource, UserProfileSession } from '@jetstream/auth/types';
44
import { HTTP } from '@jetstream/shared/constants';
55
import { getErrorMessageAndStackObj } from '@jetstream/shared/utils';
66
import { Maybe, UserProfileUi } from '@jetstream/types';
7+
import { randomUUID } from 'crypto';
8+
import { fromUnixTime } from 'date-fns';
79
import * as express from 'express';
810
import jwt from 'fast-jwt';
911
import { LRUCache } from 'lru-cache';
1012
import * as webExtDb from '../db/web-extension.db';
13+
import { hashToken } from '../services/jwt-token-encryption.service';
1114
import { AuthenticationError } from '../utils/error-handler';
1215

1316
const cache = new LRUCache<string, JwtDecodedPayload>({ max: 500 });
@@ -16,8 +19,9 @@ export const AUDIENCE_WEB_EXT = 'https://getjetstream.app/web-extension';
1619
export const AUDIENCE_DESKTOP = 'https://getjetstream.app/desktop-app';
1720
const ISSUER = 'https://getjetstream.app';
1821

19-
export const TOKEN_AUTO_REFRESH_DAYS = 7;
22+
export const TOKEN_AUTO_REFRESH_DAYS = 2;
2023
const TOKEN_EXPIRATION = 60 * 60 * 24 * 90 * 1000; // 90 days
24+
export const TOKEN_EXPIRATION_SHORT = 60 * 60 * 24 * 7 * 1000; // 7 days
2125

2226
export 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

5559
async 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

65126
export function decodeToken(token: string): JwtDecodedPayload {

apps/api/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) {
225225
HTTP.HEADERS.AUTHORIZATION,
226226
HTTP.HEADERS.X_EXT_DEVICE_ID,
227227
HTTP.HEADERS.X_WEB_EXTENSION_DEVICE_ID,
228+
HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION,
228229
].join(', ');
229230
app.use('/web-extension/*splat', (req: express.Request, res: express.Response, next: express.NextFunction) => {
230231
if (

apps/jetstream-desktop/src/services/api.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const AuthResponseSuccessSchema = z.object({
1010
success: z.literal(true),
1111
userProfile: UserProfileUiSchema,
1212
encryptionKey: z.string().length(64),
13+
accessToken: z.string().optional(),
1314
});
1415
const AuthResponseErrorSchema = z.object({ success: z.literal(false), error: z.string() });
1516
const SuccessOrErrorSchema = z.union([AuthResponseSuccessSchema, AuthResponseErrorSchema]);
@@ -26,6 +27,7 @@ export async function verifyAuthToken({ accessToken, deviceId }: { deviceId: str
2627
Authorization: `Bearer ${accessToken}`,
2728
[HTTP.HEADERS.X_APP_VERSION]: app.getVersion(),
2829
[HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId,
30+
[HTTP.HEADERS.X_SUPPORTS_TOKEN_ROTATION]: '1',
2931
},
3032
});
3133

0 commit comments

Comments
 (0)