Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions apps/api/src/app/controllers/desktop-app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const routeDefinition = {
success: z.literal(true),
userProfile: UserProfileUiSchema,
encryptionKey: z.string(),
Comment thread
paustint marked this conversation as resolved.
accessToken: z.string().optional(),
}),
z.object({
success: z.literal(false),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
);
Comment thread
paustint marked this conversation as resolved.
await webExtDb.create(user.id, {
type: webExtDb.TOKEN_TYPE_AUTH,
source: webExtDb.TOKEN_SOURCE_DESKTOP,
Expand All @@ -187,34 +192,67 @@ 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 }, '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) {
Comment thread
paustint marked this conversation as resolved.
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);
}
Comment thread
paustint marked this conversation as resolved.
res.log.info({ userId: user.id, deviceId }, 'User logged out of desktop app');

sendJson(res, { success: true });
Expand Down
59 changes: 53 additions & 6 deletions apps/api/src/app/controllers/web-extension.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
Comment thread
paustint marked this conversation as resolved.
await webExtDb.create(user.id, {
type: webExtDb.TOKEN_TYPE_AUTH,
source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION,
Expand All @@ -162,30 +177,62 @@ 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 });
Comment thread
paustint marked this conversation as resolved.
} 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) {
throw new InvalidSession();
}
// 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);
}
Comment thread
paustint marked this conversation as resolved.
res.log.info({ userId: user.id, deviceId }, 'User logged out of browser extension');

sendJson(res, { success: true });
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/app/db/web-extension.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
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 },
Expand Down
77 changes: 69 additions & 8 deletions apps/api/src/app/services/external-auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, JwtDecodedPayload>({ max: 500 });
Expand All @@ -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
Comment thread
paustint marked this conversation as resolved.

export type Audience = typeof AUDIENCE_WEB_EXT | typeof AUDIENCE_DESKTOP;

Expand All @@ -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',
Expand All @@ -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);
}
Comment thread
paustint marked this conversation as resolved.

/**
* 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<string | undefined> {
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,
Comment thread
paustint marked this conversation as resolved.
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;
Comment thread
paustint marked this conversation as resolved.
}

export function decodeToken(token: string): JwtDecodedPayload {
Expand Down Expand Up @@ -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 }));
}
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
paustint marked this conversation as resolved.
HTTP.HEADERS.X_APP_VERSION,
].join(', ');
app.use('/web-extension/*splat', (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (
Expand Down
Loading
Loading