From 3a80f5dc3a513d10995a01d4066bfec2410bac05 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 28 Dec 2025 13:53:32 -0600 Subject: [PATCH 1/4] feat: enable team dashboard in desktop app Allow access to the team dashboard from the desktop app If allowed, a link to the web-app is shown in the menu --- apps/jetstream-desktop/src/browser/browser.ts | 2 +- .../src/services/api.service.ts | 14 ++++++++++++-- .../src/services/ipc.service.ts | 8 ++++++-- .../src/services/persistence.service.ts | 19 ++++++++++++++----- libs/auth/acl/src/lib/acl.ts | 4 ++++ 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/jetstream-desktop/src/browser/browser.ts b/apps/jetstream-desktop/src/browser/browser.ts index cfaa89e2f..bfc989d81 100644 --- a/apps/jetstream-desktop/src/browser/browser.ts +++ b/apps/jetstream-desktop/src/browser/browser.ts @@ -40,7 +40,7 @@ export class Browser { const clientUrl = new URL(ENV.CLIENT_URL); // Open all external links in the browser // FIXME: this should be more protective over what is externally opened - if (url.hostname !== clientUrl.hostname) { + if (url.host !== clientUrl.host) { shell.openExternal(details.url); return { action: 'deny' }; } diff --git a/apps/jetstream-desktop/src/services/api.service.ts b/apps/jetstream-desktop/src/services/api.service.ts index 2a8ca68a7..3f87214df 100644 --- a/apps/jetstream-desktop/src/services/api.service.ts +++ b/apps/jetstream-desktop/src/services/api.service.ts @@ -1,12 +1,17 @@ import { NotificationMessageV1Response, NotificationMessageV1ResponseSchema } from '@jetstream/desktop/types'; import { HTTP } from '@jetstream/shared/constants'; -import { Maybe } from '@jetstream/types'; +import { Maybe, UserProfileUiSchema } from '@jetstream/types'; import { app, net } from 'electron'; import logger from 'electron-log'; import { z } from 'zod'; import { ENV } from '../config/environment'; -const SuccessOrErrorSchema = z.union([z.object({ success: z.literal(true) }), z.object({ success: z.literal(false), error: z.string() })]); +const AuthResponseSuccessSchema = z.object({ success: z.literal(true), userProfile: UserProfileUiSchema }); +const AuthResponseErrorSchema = z.object({ success: z.literal(false), error: z.string() }); +const SuccessOrErrorSchema = z.union([AuthResponseSuccessSchema, AuthResponseErrorSchema]); + +export type AuthResponseSuccess = z.infer; +export type AuthResponseError = z.infer; export async function verifyAuthToken(payload: { deviceId: string; accessToken: string }) { const response = await net.fetch(`${ENV.SERVER_URL}/desktop-app/auth/verify`, { @@ -14,6 +19,8 @@ export async function verifyAuthToken(payload: { deviceId: string; accessToken: headers: { 'Content-Type': 'application/json', Accept: 'application/json', + [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), + [HTTP.HEADERS.X_EXT_DEVICE_ID]: payload.deviceId, }, body: JSON.stringify(payload), }); @@ -39,6 +46,8 @@ export async function logout(payload: { deviceId: string; accessToken: string }) headers: { 'Content-Type': 'application/json', Accept: 'application/json', + [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), + [HTTP.HEADERS.X_EXT_DEVICE_ID]: payload.deviceId, }, body: JSON.stringify(payload), }); @@ -74,6 +83,7 @@ export async function checkNotifications({ headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, + [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, }); diff --git a/apps/jetstream-desktop/src/services/ipc.service.ts b/apps/jetstream-desktop/src/services/ipc.service.ts index c03312312..e51b451a8 100644 --- a/apps/jetstream-desktop/src/services/ipc.service.ts +++ b/apps/jetstream-desktop/src/services/ipc.service.ts @@ -22,7 +22,7 @@ import { checkForUpdates, getCurrentUpdateStatus, installUpdate } from '../confi import { ENV } from '../config/environment'; import { desktopRoutes } from '../controllers/desktop.routes'; import { getOrgFromHeaderOrQuery, initApiConnection } from '../utils/route.utils'; -import { logout, verifyAuthToken } from './api.service'; +import { AuthResponseSuccess, logout, verifyAuthToken } from './api.service'; import { deepLink } from './deep-link.service'; import * as dataService from './persistence.service'; import { initConnectionFromOAuthResponse } from './sfdc-oauth.service'; @@ -121,7 +121,11 @@ const handleLoginEvent: MainIpcHandler<'login'> = async (event) => { const response = await verifyAuthToken({ accessToken, deviceId }); if (response.success) { - const { userProfile } = dataService.saveAuthResponseToAppData({ deviceId, accessToken }); + const { userProfile } = dataService.saveAuthResponseToAppData({ + deviceId, + accessToken, + userProfile: (response as AuthResponseSuccess).userProfile, + }); const payload: AuthenticateSuccessPayload = { // eslint-disable-next-line @typescript-eslint/no-explicit-any userProfile: userProfile as any, diff --git a/apps/jetstream-desktop/src/services/persistence.service.ts b/apps/jetstream-desktop/src/services/persistence.service.ts index e37892be9..e448b35c0 100644 --- a/apps/jetstream-desktop/src/services/persistence.service.ts +++ b/apps/jetstream-desktop/src/services/persistence.service.ts @@ -90,8 +90,16 @@ export function setAppData(appData: AppData) { } } -export function saveAuthResponseToAppData({ deviceId, accessToken }: { deviceId: string; accessToken: string }): AppData { - const { exp, userProfile } = jwtDecode(accessToken); +export function saveAuthResponseToAppData({ + deviceId, + accessToken, + userProfile, +}: { + deviceId: string; + accessToken: string; + userProfile: UserProfileUiDesktop; +}): AppData { + const { exp } = jwtDecode(accessToken); const expiresAt = exp ? fromUnixTime(exp) : new Date(); const authState: AppData = { deviceId, @@ -128,10 +136,10 @@ export function getFullUserProfile() { const userProfile: UserProfileUiDesktop = { id: appData.userProfile.id, userId: appData.userProfile.id, - email: appData.userProfile.name, - name: appData.userProfile.email, + email: appData.userProfile.email, + name: appData.userProfile.name, emailVerified: true, - picture: null, + picture: appData.userProfile.picture, preferences: userPreferences, entitlements: { googleDrive: false, @@ -139,6 +147,7 @@ export function getFullUserProfile() { chromeExtension: false, recordSync: true, }, + teamMembership: appData.userProfile.teamMembership, subscriptions: [], }; diff --git a/libs/auth/acl/src/lib/acl.ts b/libs/auth/acl/src/lib/acl.ts index 214b8ca68..0e9e89875 100644 --- a/libs/auth/acl/src/lib/acl.ts +++ b/libs/auth/acl/src/lib/acl.ts @@ -96,6 +96,10 @@ function getAbilityRules({ isBrowserExtension, isDesktop, user }: GetAbilityOpti can('delete', 'TeamMemberSession'); } } + } else if (isDesktop && activeTeamMembership) { + if (isTeamsBillingOrAdmin) { + can(['read'], ['Team']); + } } if (user.entitlements.chromeExtension) { From 49ed2548c317846bec49b382ddf472f6c8765660 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 28 Dec 2025 13:55:23 -0600 Subject: [PATCH 2/4] chore: encrypt jwt tokens JWT tokens are used for desktop/web-extension to ensure access These tokens provide access to history data, but nothing else, they were stored unencrypted in the DB The code supports both encrypted and unencrypted tokens, so existing tokens will continue to work, but new tokens will be encrypted before being stored in the DB Tokens are encrypted as they are used if in plaintext form Fixed openapi specifications --- .env.example | 3 + .github/workflows/ci.yml | 1 + .../app/controllers/desktop-app.controller.ts | 76 +++++--- .../controllers/desktop-assets.controller.ts | 5 +- .../controllers/web-extension.controller.ts | 69 +++++--- apps/api/src/app/db/web-extension.db.ts | 51 +++++- apps/api/src/app/routes/desktop-app.routes.ts | 8 +- apps/api/src/app/routes/openapi.routes.ts | 26 ++- .../app/routes/web-extension-server.routes.ts | 8 +- .../salesforce-org-encryption.service.spec.ts | 3 +- .../services/__tests__/team.service.spec.ts | 1 + .../src/app/services/desktop-asset.service.ts | 5 +- .../services/jwt-token-encryption.service.ts | 140 +++++++++++++++ apps/api/src/main.ts | 4 +- .../src/app/components/core/Login.tsx | 8 +- apps/jetstream-desktop/src/preload.ts | 36 +++- apps/jetstream-desktop/src/utils/utils.ts | 6 +- .../external-auth-logged-in.spec.ts | 165 ++++++++++++++++++ apps/landing/hooks/desktop-auth.hooks.ts | 13 +- apps/landing/hooks/web-extension.hooks.ts | 1 + libs/api-config/src/lib/env-config.ts | 4 + .../server/src/lib/auth-logging.db.service.ts | 6 +- libs/auth/server/src/lib/auth.db.service.ts | 5 + .../src/lib/desktop-app.types.ts | 21 +-- .../constants/src/lib/shared-constants.ts | 1 + libs/shared/ui-core/src/app/HeaderNavbar.tsx | 9 +- .../migration.sql | 19 ++ prisma/schema.prisma | 3 +- scripts/generate.env.mjs | 1 + 29 files changed, 589 insertions(+), 109 deletions(-) create mode 100644 apps/api/src/app/services/jwt-token-encryption.service.ts create mode 100644 apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts create mode 100644 prisma/migrations/20251228000000_add_token_hash_to_web_extension_token/migration.sql diff --git a/.env.example b/.env.example index 8ba32ff0d..3035236f2 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,9 @@ AUTH_SFDC_CLIENT_SECRET='' AUTH_GOOGLE_CLIENT_ID='' AUTH_GOOGLE_CLIENT_SECRET='' +# Generate using `openssl rand -base64 32` +JWT_ENCRYPTION_KEY='' + # SALESFORCE CONFIGURATION # You must provide your own keys by creating a connected app in your dev or production org. # Scopes: api, web, refresh_token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee1bcce54..f16e36299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ env: SFDC_CONSUMER_KEY: ${{ secrets.SFDC_CONSUMER_KEY }} SFDC_CONSUMER_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }} SFDC_ENCRYPTION_KEY: ${{ secrets.SFDC_ENCRYPTION_KEY }} + JWT_ENCRYPTION_KEY: ${{ secrets.JWT_ENCRYPTION_KEY }} jobs: # Build application diff --git a/apps/api/src/app/controllers/desktop-app.controller.ts b/apps/api/src/app/controllers/desktop-app.controller.ts index 750ec545e..2c99887fe 100644 --- a/apps/api/src/app/controllers/desktop-app.controller.ts +++ b/apps/api/src/app/controllers/desktop-app.controller.ts @@ -1,8 +1,15 @@ import { ENV } from '@jetstream/api-config'; -import { getApiAddressFromReq, getCookieConfig, InvalidSession, MissingEntitlement } from '@jetstream/auth/server'; +import { + createUserActivityFromReq, + getApiAddressFromReq, + getCookieConfig, + InvalidSession, + MissingEntitlement, +} from '@jetstream/auth/server'; import { NotificationMessageV1Response } from '@jetstream/desktop/types'; import { HTTP } from '@jetstream/shared/constants'; import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; +import { UserProfileUiSchema } from '@jetstream/types'; import { fromUnixTime } from 'date-fns'; import { z } from 'zod'; import * as userSyncDbService from '../db/data-sync.db'; @@ -11,6 +18,7 @@ import { checkUserEntitlement } from '../db/user.db'; import * as webExtDb from '../db/web-extension.db'; import { emitRecordSyncEventsToOtherClients, SyncEvent } from '../services/data-sync-broadcast.service'; import * as externalAuthService from '../services/external-auth.service'; +import { decryptJwtTokenOrPlaintext } from '../services/jwt-token-encryption.service'; import { redirect, sendJson } from '../utils/response.handlers'; import { createRoute } from '../utils/route.utils'; import { routeDefinition as dataSyncController } from './data-sync.controller'; @@ -43,9 +51,9 @@ export const routeDefinition = { hasSourceOrg: false, }, }, - verifyTokens: { - controllerFn: () => verifyTokens, - responseType: z.object({ success: z.boolean(), error: z.string().nullish() }), + verifyToken: { + controllerFn: () => verifyToken, + responseType: z.object({ success: z.boolean(), error: z.string().nullish(), userProfile: UserProfileUiSchema.optional() }), validators: { body: z.object({ deviceId: z.string(), @@ -111,44 +119,62 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ return; } - let accessToken = ''; - const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id, omitSubscriptions: true }); - const existingRecord = await webExtDb.findByUserIdAndDeviceId({ + + // Check for existing valid token with refresh buffer (7 days) + const existingTokenRecord = await webExtDb.findByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH, expiresAtBufferDays: externalAuthService.TOKEN_AUTO_REFRESH_DAYS, }); - // issue a new token if one does not exist withing the auto-refresh buffer - if (!existingRecord) { - accessToken = await externalAuthService.issueAccessToken(userProfile, externalAuthService.AUDIENCE_DESKTOP); - await webExtDb.create(user.id, { - type: 'AUTH_TOKEN', - source: webExtDb.TOKEN_SOURCE_DESKTOP, - token: accessToken, - deviceId, - ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), - userAgent: req.get('User-Agent') || 'unknown', - expiresAt: fromUnixTime(externalAuthService.decodeToken(accessToken).exp), - }); - } else { - // return existing token since it is still valid - accessToken = existingRecord.token; + if (existingTokenRecord) { + // Decrypt the stored token and return it (supports both encrypted and legacy plaintext) + const decryptedToken = decryptJwtTokenOrPlaintext(existingTokenRecord.token); + const decoded = externalAuthService.decodeToken(decryptedToken); + const expiresAt = fromUnixTime(decoded.exp); + + res.log.info({ userId: user.id, deviceId, expiresAt }, 'Reusing existing desktop token'); + + sendJson(res, { accessToken: decryptedToken }); + return; } + // Issue new token if none exists or about to expire + const accessToken = await externalAuthService.issueAccessToken(userProfile, externalAuthService.AUDIENCE_DESKTOP); + await webExtDb.create(user.id, { + type: webExtDb.TOKEN_TYPE_AUTH, + source: webExtDb.TOKEN_SOURCE_DESKTOP, + token: accessToken, + deviceId, + ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), + userAgent: req.get('User-Agent') || 'unknown', + expiresAt: fromUnixTime(externalAuthService.decodeToken(accessToken).exp), + }); + + res.log.info({ userId: user.id, deviceId }, 'Issued new desktop token'); + sendJson(res, { accessToken }); + + createUserActivityFromReq(req, res, { + action: 'DESKTOP_LOGIN_TOKEN_ISSUED', + success: true, + }); }); -const verifyTokens = createRoute(routeDefinition.verifyTokens.validators, async ({ body }, _, res) => { +const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ body }, _, res) => { try { const { accessToken, deviceId } = body; // This validates the token against the database record - const { userProfile } = await externalAuthService.verifyToken({ token: accessToken, deviceId }, externalAuthService.AUDIENCE_DESKTOP); + const { userProfile: userProfileJwt } = await externalAuthService.verifyToken( + { token: accessToken, deviceId }, + externalAuthService.AUDIENCE_DESKTOP, + ); + const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: userProfileJwt.id, omitSubscriptions: true }); res.log.info({ userId: userProfile.id, deviceId }, 'Desktop App token verified'); - sendJson(res, { success: true }); + sendJson(res, { success: true, userProfile }); } catch (ex) { res.log.error({ deviceId: body?.deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error verifying Desktop App token'); sendJson(res, { success: false, error: 'Invalid session' }, 401); diff --git a/apps/api/src/app/controllers/desktop-assets.controller.ts b/apps/api/src/app/controllers/desktop-assets.controller.ts index 657464844..21f66d462 100644 --- a/apps/api/src/app/controllers/desktop-assets.controller.ts +++ b/apps/api/src/app/controllers/desktop-assets.controller.ts @@ -1,3 +1,4 @@ +import { logger } from '@jetstream/api-config'; import z from 'zod'; import { getLatestDesktopVersion, PlatformArch, PlatformArchSchema } from '../services/desktop-asset.service'; import { createRoute } from '../utils/route.utils'; @@ -74,7 +75,7 @@ const getDownloadLink = createRoute(routeDefinition.getDownloadLink.validators, downloadUrl, }); } catch (error) { - console.error('Download link generation failed:', error); + logger.error('Download link generation failed:', error); res.status(500).json({ error: 'Failed to generate download link' }); } }); @@ -114,7 +115,7 @@ const getAllDownloadLinks = createRoute(routeDefinition.getAllDownloadLinks.vali res.json(downloads); } catch (error) { - console.error('Failed to get all download links:', error); + logger.error('Failed to get all download links:', error); res.status(500).json({ error: 'Failed to get download links' }); } }); diff --git a/apps/api/src/app/controllers/web-extension.controller.ts b/apps/api/src/app/controllers/web-extension.controller.ts index b71cb7455..eb5cee760 100644 --- a/apps/api/src/app/controllers/web-extension.controller.ts +++ b/apps/api/src/app/controllers/web-extension.controller.ts @@ -1,5 +1,11 @@ import { ENV } from '@jetstream/api-config'; -import { getApiAddressFromReq, getCookieConfig, InvalidSession, MissingEntitlement } from '@jetstream/auth/server'; +import { + createUserActivityFromReq, + getApiAddressFromReq, + getCookieConfig, + InvalidSession, + MissingEntitlement, +} from '@jetstream/auth/server'; import { HTTP } from '@jetstream/shared/constants'; import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; import { fromUnixTime } from 'date-fns'; @@ -11,6 +17,7 @@ import { checkUserEntitlement } from '../db/user.db'; import * as webExtDb from '../db/web-extension.db'; import { emitRecordSyncEventsToOtherClients, SyncEvent } from '../services/data-sync-broadcast.service'; import * as externalAuthService from '../services/external-auth.service'; +import { decryptJwtTokenOrPlaintext } from '../services/jwt-token-encryption.service'; import { redirect, sendJson } from '../utils/response.handlers'; import { createRoute } from '../utils/route.utils'; @@ -43,8 +50,8 @@ export const routeDefinition = { hasSourceOrg: false, }, }, - verifyTokens: { - controllerFn: () => verifyTokens, + verifyToken: { + controllerFn: () => verifyToken, responseType: z.object({ success: z.boolean(), error: z.string().nullish() }), validators: { body: z.object({ @@ -102,44 +109,58 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ return; } - let accessToken = ''; - const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id, omitSubscriptions: true }); - const existingRecord = await webExtDb.findByUserIdAndDeviceId({ + const existingTokenRecord = await webExtDb.findByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH, expiresAtBufferDays: externalAuthService.TOKEN_AUTO_REFRESH_DAYS, }); - // issue a new token if one does not exist withing the auto-refresh buffer - if (!existingRecord) { - accessToken = await externalAuthService.issueAccessToken(userProfile, externalAuthService.AUDIENCE_WEB_EXT); - await webExtDb.create(user.id, { - type: 'AUTH_TOKEN', - source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION, - token: accessToken, - deviceId, - ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), - userAgent: req.get('User-Agent') || 'unknown', - expiresAt: fromUnixTime(externalAuthService.decodeToken(accessToken).exp), - }); - } else { - // return existing token since it is still valid - accessToken = existingRecord.token; + if (existingTokenRecord) { + // Decrypt the stored token and return it (token reuse) + const decryptedToken = decryptJwtTokenOrPlaintext(existingTokenRecord.token); + const decoded = externalAuthService.decodeToken(decryptedToken); + const expiresAt = fromUnixTime(decoded.exp); + + res.log.info({ userId: user.id, deviceId, expiresAt }, 'Reusing existing web extension token'); + + sendJson(res, { accessToken: decryptedToken }); + return; } + // Issue new token if none exists or about to expire + const accessToken = await externalAuthService.issueAccessToken(userProfile, externalAuthService.AUDIENCE_WEB_EXT); + await webExtDb.create(user.id, { + type: webExtDb.TOKEN_TYPE_AUTH, + source: webExtDb.TOKEN_SOURCE_BROWSER_EXTENSION, + token: accessToken, + deviceId, + ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), + userAgent: req.get('User-Agent') || 'unknown', + expiresAt: fromUnixTime(externalAuthService.decodeToken(accessToken).exp), + }); + sendJson(res, { accessToken }); + + createUserActivityFromReq(req, res, { + action: 'WEB_EXTENSION_LOGIN_TOKEN_ISSUED', + success: true, + }); }); -const verifyTokens = createRoute(routeDefinition.verifyTokens.validators, async ({ body }, _, res) => { +const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ body }, _, res) => { try { const { accessToken, deviceId } = body; // This validates the token against the database record - const { userProfile } = await externalAuthService.verifyToken({ token: accessToken, deviceId }, externalAuthService.AUDIENCE_WEB_EXT); + const { userProfile: userProfileJwt } = await externalAuthService.verifyToken( + { token: accessToken, deviceId }, + externalAuthService.AUDIENCE_WEB_EXT, + ); + const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: userProfileJwt.id, omitSubscriptions: true }); res.log.info({ userId: userProfile.id, deviceId }, 'Web extension token verified'); - sendJson(res, { success: true }); + sendJson(res, { success: true, userProfile }); } catch (ex) { res.log.error({ deviceId: body?.deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error verifying web extension token'); sendJson(res, { success: false, error: 'Invalid session' }, 401); diff --git a/apps/api/src/app/db/web-extension.db.ts b/apps/api/src/app/db/web-extension.db.ts index b7476edf8..a2ab2b98e 100644 --- a/apps/api/src/app/db/web-extension.db.ts +++ b/apps/api/src/app/db/web-extension.db.ts @@ -1,7 +1,9 @@ -import { prisma } from '@jetstream/api-config'; +import { logger, prisma } from '@jetstream/api-config'; import { TokenSource, TokenSourceBrowserExtensions, TokenSourceDesktop } from '@jetstream/auth/types'; import { Prisma } from '@jetstream/prisma'; +import { getErrorMessage } from '@jetstream/shared/utils'; import { addDays } from 'date-fns'; +import { encryptJwtToken, hashToken, isTokenEncrypted } from '../services/jwt-token-encryption.service'; export type TokenTypeAuthToken = 'AUTH_TOKEN'; export type TokenType = TokenTypeAuthToken; @@ -32,6 +34,7 @@ const SELECT = { type: true, source: true, token: true, + tokenHash: true, deviceId: true, ipAddress: true, userAgent: true, @@ -67,14 +70,48 @@ export const findByUserIdAndDeviceId = async ({ }); }; +/** + * Upgrade a legacy token (plaintext) to encrypted format + * This is called automatically when we encounter an old token during migration + */ +async function upgradeLegacyToken(recordId: string, plaintextToken: string): Promise { + try { + const encryptedToken = encryptJwtToken(plaintextToken); + const tokenHash = hashToken(plaintextToken); + + await prisma.webExtensionToken.update({ + where: { id: recordId }, + data: { + token: encryptedToken, + tokenHash, + }, + }); + } catch (error) { + logger.error({ error: getErrorMessage(error) }, 'Failed to upgrade legacy token'); + } +} + export const findByAccessTokenAndDeviceId = async ({ token, deviceId, type }: { token: string; deviceId: string; type: TokenType }) => { - return await prisma.webExtensionToken.findUnique({ + const tokenHashValue = hashToken(token); + + const record = await prisma.webExtensionToken.findUnique({ where: { - type_token_deviceId: { type, deviceId, token }, + type_tokenHash_deviceId: { type, deviceId, tokenHash: tokenHashValue }, expiresAt: { gt: new Date() }, }, select: SELECT, }); + + // TEMPORARY: This is here to prevent breaking changes during migration + // After all tokens have been encrypted, we can remove this block + // Auto-upgrade legacy tokens to encrypted format + if (record && !isTokenEncrypted(record.token)) { + upgradeLegacyToken(record.id, token).catch((err) => { + logger.error('Failed to upgrade legacy token:', err); + }); + } + + return record; }; export const create = async ( @@ -89,10 +126,14 @@ export const create = async ( expiresAt: Date; }, ) => { + // Encrypt the token before storing and create hash for lookup + const token = encryptJwtToken(payload.token); + const tokenHash = hashToken(payload.token); + return await prisma.webExtensionToken.upsert({ select: SELECT, - create: { userId, ...payload }, - update: { userId, ...payload }, + create: { userId, ...payload, token, tokenHash }, + update: { userId, ...payload, token, tokenHash }, where: { type_userId_deviceId: { type: payload.type, userId, deviceId: payload.deviceId }, }, diff --git a/apps/api/src/app/routes/desktop-app.routes.ts b/apps/api/src/app/routes/desktop-app.routes.ts index b6069a6a0..37ac8074c 100644 --- a/apps/api/src/app/routes/desktop-app.routes.ts +++ b/apps/api/src/app/routes/desktop-app.routes.ts @@ -56,10 +56,14 @@ const authMiddleware = externalAuthService.getExternalAuthMiddleware(externalAut // NOTE: MIDDLEWARE ROUTE - will either redirect to login or will call next() to allow static page to be served routes.get('/auth', LAX_AuthRateLimit, desktopAppController.routeDefinition.initAuthMiddleware.controllerFn()); -// API endpoint that /auth/desktop calls to get tokens to avoid having them defined in the HTML directly - this endpoint issues tokens +/** + * @deprecated - use POST instead + */ routes.get('/auth/session', STRICT_2X_AuthRateLimit, desktopAppController.routeDefinition.initSession.controllerFn()); +// API endpoint that /auth/desktop calls to get tokens to avoid having them defined in the HTML directly - this endpoint issues tokens +routes.post('/auth/session', STRICT_2X_AuthRateLimit, desktopAppController.routeDefinition.initSession.controllerFn()); // Validate authentication status from desktop app -routes.post('/auth/verify', STRICT_AuthRateLimit, desktopAppController.routeDefinition.verifyTokens.controllerFn()); +routes.post('/auth/verify', STRICT_AuthRateLimit, desktopAppController.routeDefinition.verifyToken.controllerFn()); routes.delete('/auth/logout', STRICT_AuthRateLimit, desktopAppController.routeDefinition.logout.controllerFn()); /** diff --git a/apps/api/src/app/routes/openapi.routes.ts b/apps/api/src/app/routes/openapi.routes.ts index 4124b63ad..393bb93a6 100644 --- a/apps/api/src/app/routes/openapi.routes.ts +++ b/apps/api/src/app/routes/openapi.routes.ts @@ -565,17 +565,15 @@ export function getOpenApiSpec() { post: { ...getRequest({ ...billingController.createBillingPortalSession.validators, tags: ['billing'] }) }, }, - // Web Extension Controller Routes (prefix: /desktop-app) - '/desktop-app/init': { - get: { ...getRequest({ ...desktopController.initAuthMiddleware.validators, tags: ['desktop'] }) }, + // Desktop App Controller Routes (prefix: /desktop-app) + '/desktop-app/auth/session': { + get: { ...getRequest({ ...desktopController.initSession.validators, tags: ['desktop'] }), deprecated: true }, + post: { ...getRequest({ ...desktopController.initSession.validators, tags: ['desktop'] }) }, }, - '/desktop-app/session': { - get: { ...getRequest({ ...desktopController.initSession.validators, tags: ['desktop'] }) }, + '/desktop-app/auth/verify': { + post: { ...getRequest({ ...desktopController.verifyToken.validators, tags: ['desktop'] }) }, }, - '/desktop-app/verify': { - post: { ...getRequest({ ...desktopController.verifyTokens.validators, tags: ['desktop'] }) }, - }, - '/desktop-app/logout': { + '/desktop-app/auth/logout': { delete: { ...getRequest({ ...desktopController.logout.validators, tags: ['desktop'] }) }, }, '/desktop-app/data-sync/pull': { @@ -584,19 +582,17 @@ export function getOpenApiSpec() { '/desktop-app/data-sync/push': { post: { ...getRequest({ ...desktopController.dataSyncPush.validators, tags: ['desktop'] }) }, }, - '/v1/notifications': { + '/desktop-app/v1/notifications': { post: { ...getRequest({ ...desktopController.notifications.validators, tags: ['desktop'] }) }, }, // Web Extension Controller Routes (prefix: /web-extension) - '/web-extension/init': { - get: { ...getRequest({ ...webExtensionController.initAuthMiddleware.validators, tags: ['webExtension'] }) }, - }, '/web-extension/session': { - get: { ...getRequest({ ...webExtensionController.initSession.validators, tags: ['webExtension'] }) }, + get: { ...getRequest({ ...webExtensionController.initSession.validators, tags: ['webExtension'] }), deprecated: true }, + post: { ...getRequest({ ...webExtensionController.initSession.validators, tags: ['webExtension'] }) }, }, '/web-extension/verify': { - post: { ...getRequest({ ...webExtensionController.verifyTokens.validators, tags: ['webExtension'] }) }, + post: { ...getRequest({ ...webExtensionController.verifyToken.validators, tags: ['webExtension'] }) }, }, '/web-extension/logout': { delete: { ...getRequest({ ...webExtensionController.logout.validators, tags: ['webExtension'] }) }, diff --git a/apps/api/src/app/routes/web-extension-server.routes.ts b/apps/api/src/app/routes/web-extension-server.routes.ts index c135aba6c..428bfdca5 100644 --- a/apps/api/src/app/routes/web-extension-server.routes.ts +++ b/apps/api/src/app/routes/web-extension-server.routes.ts @@ -56,10 +56,14 @@ const authMiddleware = externalAuthService.getExternalAuthMiddleware(externalAut // NOTE: MIDDLEWARE ROUTE - will either redirect to login or will call next() to allow static page to be served routes.get('/init', LAX_AuthRateLimit, webExtensionController.routeDefinition.initAuthMiddleware.controllerFn()); -// API endpoint that /init calls to get tokens to avoid having them defined in the HTML directly - this endpoint issues tokens +/** + * @deprecated - use POST instead + */ routes.get('/session', STRICT_2X_AuthRateLimit, webExtensionController.routeDefinition.initSession.controllerFn()); +// API endpoint that /init calls to get tokens to avoid having them defined in the HTML directly - this endpoint issues tokens +routes.post('/session', STRICT_2X_AuthRateLimit, webExtensionController.routeDefinition.initSession.controllerFn()); // Validate authentication status from browser extension -routes.post('/verify', STRICT_AuthRateLimit, webExtensionController.routeDefinition.verifyTokens.controllerFn()); +routes.post('/verify', STRICT_AuthRateLimit, webExtensionController.routeDefinition.verifyToken.controllerFn()); routes.delete('/logout', STRICT_AuthRateLimit, webExtensionController.routeDefinition.logout.controllerFn()); /** diff --git a/apps/api/src/app/services/__tests__/salesforce-org-encryption.service.spec.ts b/apps/api/src/app/services/__tests__/salesforce-org-encryption.service.spec.ts index cd3902222..3865b489f 100644 --- a/apps/api/src/app/services/__tests__/salesforce-org-encryption.service.spec.ts +++ b/apps/api/src/app/services/__tests__/salesforce-org-encryption.service.spec.ts @@ -1,5 +1,5 @@ -import { vi, Mock } from 'vitest'; import { decryptString, encryptString } from '@jetstream/shared/node-utils'; +import { Mock, vi } from 'vitest'; import { decryptAccessToken, encryptAccessToken } from '../salesforce-org-encryption.service'; vi.mock('@jetstream/shared/node-utils', () => ({ @@ -10,6 +10,7 @@ vi.mock('@jetstream/shared/node-utils', () => ({ vi.mock('@jetstream/api-config', () => ({ ENV: { + JWT_ENCRYPTION_KEY: 'test-jwt-key', SFDC_ENCRYPTION_KEY: 'test-master-key', SFDC_ENCRYPTION_CACHE_MAX_ENTRIES: 10000, SFDC_ENCRYPTION_CACHE_TTL_MS: 3600000, diff --git a/apps/api/src/app/services/__tests__/team.service.spec.ts b/apps/api/src/app/services/__tests__/team.service.spec.ts index 790119f56..d723c92e0 100644 --- a/apps/api/src/app/services/__tests__/team.service.spec.ts +++ b/apps/api/src/app/services/__tests__/team.service.spec.ts @@ -34,6 +34,7 @@ vi.mock('@jetstream/shared/node-utils', () => ({ vi.mock('@jetstream/api-config', () => ({ ENV: { + JWT_ENCRYPTION_KEY: 'test-jwt-key', SFDC_ENCRYPTION_KEY: 'test-master-key', SFDC_ENCRYPTION_CACHE_MAX_ENTRIES: 10000, SFDC_ENCRYPTION_CACHE_TTL_MS: 3600000, diff --git a/apps/api/src/app/services/desktop-asset.service.ts b/apps/api/src/app/services/desktop-asset.service.ts index f50acbd84..739c53f48 100644 --- a/apps/api/src/app/services/desktop-asset.service.ts +++ b/apps/api/src/app/services/desktop-asset.service.ts @@ -1,5 +1,6 @@ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { ENV } from '@jetstream/api-config'; +import { ENV, logger } from '@jetstream/api-config'; +import { getErrorMessage } from '@jetstream/shared/utils'; import { load } from 'js-yaml'; import { z } from 'zod'; @@ -113,7 +114,7 @@ export async function getLatestDesktopVersion({ arch, platform }: PlatformArch): return cached.data; } catch (error) { - console.error(`Failed to get latest version for ${platform}/${arch}:`, error); + logger.error({ error: getErrorMessage(error) }, `Failed to get latest version for ${platform}/${arch}`); // Cache null result to avoid repeated failures versionCache.set(cacheKey, { data: null, expiry: Date.now() + CACHE_DURATION_MS }); return null; diff --git a/apps/api/src/app/services/jwt-token-encryption.service.ts b/apps/api/src/app/services/jwt-token-encryption.service.ts new file mode 100644 index 000000000..475520353 --- /dev/null +++ b/apps/api/src/app/services/jwt-token-encryption.service.ts @@ -0,0 +1,140 @@ +import { ENV, getExceptionLog, logger, rollbarServer } from '@jetstream/api-config'; +import { decryptString, encryptString } from '@jetstream/shared/node-utils'; +import { getErrorMessage } from '@jetstream/shared/utils'; +import { createHash } from 'crypto'; + +/** + * Encryption service for JWT access tokens stored in the database + * + * Uses AES-256-CBC encryption with the JWT_ENCRYPTION_KEY to encrypt JWT tokens + * before storing them in the database for compliance and defense-in-depth. + * + * Token hash (SHA-256) is stored alongside encrypted token for efficient lookups. + */ + +/** + * Encrypt a JWT token before storing in database + * + * @param token - The JWT token to encrypt + * @returns Encrypted token string in format "iv!encryptedData" + */ +export function encryptJwtToken(token: string): string { + if (!token || token.length === 0) { + throw new Error('Token cannot be empty'); + } + + const encryptionKey = ENV.JWT_ENCRYPTION_KEY; + + if (!encryptionKey) { + throw new Error('JWT_ENCRYPTION_KEY is not configured'); + } + + try { + return encryptString(token, encryptionKey); + } catch (error) { + logger.error({ ...getExceptionLog(error) }, 'Failed to encrypt JWT token'); + rollbarServer.error('Failed to encrypt JWT token', { + context: 'jwt-token-encryption.service#encryptJwtToken', + custom: { + ...getExceptionLog(error, true), + }, + }); + throw new Error('Failed to encrypt token'); + } +} + +/** + * Decrypt a JWT token retrieved from database + * + * @param encryptedToken - The encrypted token from database + * @returns Decrypted JWT token string + */ +export function decryptJwtToken(encryptedToken: string): string { + if (!encryptedToken || encryptedToken.length === 0) { + throw new Error('Encrypted token cannot be empty'); + } + + const encryptionKey = ENV.JWT_ENCRYPTION_KEY; + + if (!encryptionKey) { + throw new Error('JWT_ENCRYPTION_KEY is not configured'); + } + + try { + return decryptString(encryptedToken, encryptionKey); + } catch (error) { + logger.error({ ...getExceptionLog(error) }, 'Failed to decrypt JWT token'); + rollbarServer.error('Failed to decrypt JWT token', { + context: 'jwt-token-encryption.service#decryptJwtToken', + custom: { + ...getExceptionLog(error, true), + }, + }); + throw new Error('Failed to decrypt token'); + } +} + +/** + * Generate SHA-256 hash of a token for database lookups + * + * This hash is stored in the tokenHash column to enable efficient + * queries without decrypting all tokens. + * + * @param token - The plain JWT token (before encryption) + * @returns SHA-256 hash as hex string (64 characters) + */ +export function hashToken(token: string): string { + if (!token || token.length === 0) { + throw new Error('Token cannot be empty'); + } + + return createHash('sha256').update(token).digest('hex'); +} + +/** + * Detect if a token is encrypted based on format + * Encrypted tokens have the format "iv!encryptedData" where ! is the separator + * + * @param token - Token to check + * @returns true if token appears to be encrypted + */ +export function isTokenEncrypted(token: string): boolean { + // Encrypted tokens always contain "!" separator between IV and encrypted data + // JWT tokens never contain "!" character, so this is a safe check + return token.includes('!'); +} + +/** + * Attempt to decrypt a token that might be in legacy (unencrypted) format + * + * This supports backward compatibility during migration from unencrypted to encrypted tokens. + * If decryption fails, assumes the token is already in plain text format. + * + * TODO: after full migration we can remove this in favor of always using decryptJwtToken + * Ticket for removal: #1494 + * + * @param possiblyEncryptedToken - Token that might be encrypted or plain text + * @returns Decrypted token or original token if it was plain text + */ +export function decryptJwtTokenOrPlaintext(possiblyEncryptedToken: string): string { + if (!possiblyEncryptedToken || possiblyEncryptedToken.length === 0) { + throw new Error('Token cannot be empty'); + } + + // If token looks encrypted, decrypt it + if (isTokenEncrypted(possiblyEncryptedToken)) { + try { + return decryptJwtToken(possiblyEncryptedToken); + } catch (ex) { + // If decryption fails, it might be a legacy token that happens to have "!" in it + logger.warn( + { error: getErrorMessage(ex), token: possiblyEncryptedToken.substring(0, 20) }, + 'Failed to decrypt token, treating as plaintext (legacy format)', + ); + return possiblyEncryptedToken; + } + } + + // Token doesn't look encrypted, return as-is (legacy plaintext format) + return possiblyEncryptedToken; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7e1275de0..7af0829e2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -411,8 +411,8 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) { server.on('error', (error) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((error as any).code === 'EADDRINUSE') { - console.error(`Port ${ENV.PORT} is already in use`, error.message); - console.info('Kill with: lsof -ti:3333 | xargs kill -9'); + logger.info('Kill with: lsof -ti:3333 | xargs kill -9'); + logger.error(getExceptionLog(error), `Port ${ENV.PORT} is already in use`); process.exit(1); } else { logger.error(getExceptionLog(error), '[SERVER][ERROR]'); diff --git a/apps/jetstream-desktop-client/src/app/components/core/Login.tsx b/apps/jetstream-desktop-client/src/app/components/core/Login.tsx index 40e3fb536..fff3c7d67 100644 --- a/apps/jetstream-desktop-client/src/app/components/core/Login.tsx +++ b/apps/jetstream-desktop-client/src/app/components/core/Login.tsx @@ -42,7 +42,10 @@ export function Login({ children }: LoginProps) { if (!window.electronAPI) { return; } - window.electronAPI.onAuthenticate(authenticationEventHandler); + + // Register authentication event listener and get cleanup function + const unsubscribeAuth = window.electronAPI.onAuthenticate(authenticationEventHandler); + window.electronAPI .checkAuth() .then((response) => { @@ -54,7 +57,7 @@ export function Login({ children }: LoginProps) { }) .finally(() => setLoading(false)); - // Check auth every 12 hours + // Check auth occasionally in case of token expiry or revocation const interval = setInterval(() => { window.electronAPI?.checkAuth().then((response) => { if (response) { @@ -70,6 +73,7 @@ export function Login({ children }: LoginProps) { return () => { clearInterval(interval); + unsubscribeAuth(); }; }, [authenticationEventHandler, setUserProfile]); diff --git a/apps/jetstream-desktop/src/preload.ts b/apps/jetstream-desktop/src/preload.ts index ea70f51e1..fdce116a2 100644 --- a/apps/jetstream-desktop/src/preload.ts +++ b/apps/jetstream-desktop/src/preload.ts @@ -3,12 +3,36 @@ import { contextBridge, ipcRenderer } from 'electron'; const API: ElectronAPI = { // One-Way to Client - onAuthenticate: (callback) => ipcRenderer.on(IpcEventChannel.authenticate, (_event, payload) => callback(payload)), - onOrgAdded: (callback) => ipcRenderer.on(IpcEventChannel.orgAdded, (_event, payload) => callback(payload)), - onAction: (callback) => ipcRenderer.on(IpcEventChannel.action, (_event, payload) => callback(payload)), - onUpdateStatus: (callback) => ipcRenderer.on(IpcEventChannel.updateStatus, (_event, payload) => callback(payload)), - onDownloadProgress: (callback) => ipcRenderer.on(IpcEventChannel.downloadProgress, (_event, payload) => callback(payload)), - onToastMessage: (callback) => ipcRenderer.on(IpcEventChannel.toastMessage, (_event, payload) => callback(payload)), + onAuthenticate: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on(IpcEventChannel.authenticate, handler); + return () => ipcRenderer.removeListener(IpcEventChannel.authenticate, handler); + }, + onOrgAdded: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on(IpcEventChannel.orgAdded, handler); + return () => ipcRenderer.removeListener(IpcEventChannel.orgAdded, handler); + }, + onAction: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on(IpcEventChannel.action, handler); + return () => ipcRenderer.removeListener(IpcEventChannel.action, handler); + }, + onUpdateStatus: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on(IpcEventChannel.updateStatus, handler); + return () => ipcRenderer.removeListener(IpcEventChannel.updateStatus, handler); + }, + onDownloadProgress: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on(IpcEventChannel.downloadProgress, handler); + return () => ipcRenderer.removeListener(IpcEventChannel.downloadProgress, handler); + }, + onToastMessage: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on(IpcEventChannel.toastMessage, handler); + return () => ipcRenderer.removeListener(IpcEventChannel.toastMessage, handler); + }, // One-Way from Client login: () => ipcRenderer.invoke('login'), logout: () => ipcRenderer.invoke('logout'), diff --git a/apps/jetstream-desktop/src/utils/utils.ts b/apps/jetstream-desktop/src/utils/utils.ts index 22c26efa6..d93c6c5a8 100644 --- a/apps/jetstream-desktop/src/utils/utils.ts +++ b/apps/jetstream-desktop/src/utils/utils.ts @@ -18,14 +18,14 @@ const CspPolicy = { `wss://${SERVER_URL.host}`, 'https://*.salesforce.com', ], - 'font-src': [`'self'`], + 'font-src': [`'self'`, 'data:'], 'frame-ancestors': ["'self'", 'getjetstream.app', '*.google.com', '*.googleapis.com', '*.gstatic.com'], // 'frame-src': [`'self'`, '*.google.com', '*.googleapis.com', '*.gstatic.com'], - 'img-src': [`'self'`, `data:`, '*.googleusercontent.com'], + 'img-src': [`'self'`, 'data:', '*.googleusercontent.com'], 'script-src': [`'self'`, `'unsafe-inline'`, '*.google.com'], 'script-src-attr': ['none'], 'style-src': [`'self'`, `'unsafe-inline'`], - 'worker-src': [`'self'`, `blob:`], + 'worker-src': [`'self'`, 'blob:'], }; export function getCspPolicy() { diff --git a/apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts b/apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts new file mode 100644 index 000000000..744abb4ed --- /dev/null +++ b/apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts @@ -0,0 +1,165 @@ +import { HTTP } from '@jetstream/shared/constants'; +import { v4 as uuid } from 'uuid'; +import { expect, test } from '../../fixtures/fixtures'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Desktop / Web-Extension Authentication', () => { + // // Reset storage state for this file to avoid being authenticated + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page, authenticationPage }) => { + await page.goto('/'); + await authenticationPage.acceptCookieBanner(); + }); + + test('Desktop Authentication - success', async ({ page, teamCreationUtils1User }) => { + const deviceId = uuid(); + const token = uuid(); + + await page.goto(`/desktop-app/auth/?deviceId=${deviceId}&token=${token}`); + await expect(page.getByText('You are successfully authenticated, you can close this tab.')).toBeVisible(); + }); + + test('Desktop Authentication - Missing query params', async ({ page, teamCreationUtils1User }) => { + await page.goto(`/desktop-app/auth/`); + await expect(page.getByText('Error communicating with desktop application, is the application open?')).toBeVisible(); + }); + + test('Desktop Authentication - API', async ({ page, teamCreationUtils1User, apiRequestUtils }) => { + const deviceId = uuid(); + const response = await apiRequestUtils.request.post(`/desktop-app/auth/session?deviceId=${deviceId}`); + expect(response.status()).toBe(200); + const data = await response.json().then(({ data }) => data); + expect(typeof data.accessToken).toBe('string'); + + let verifyResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + data: JSON.stringify({ deviceId, accessToken: data.accessToken }), + }); + expect(verifyResponse.status()).toBe(200); + const verifyData = await verifyResponse.json().then(({ data }) => data); + expect(verifyData.success).toBe(true); + expect(verifyData.userProfile).toBeDefined(); + + verifyResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + data: JSON.stringify({ deviceId: 'invalid-device-id', accessToken: data.accessToken }), + }); + expect(verifyResponse.status()).toBe(401); + const invalidVerifyData1 = await verifyResponse.json().then(({ data }) => data); + expect(invalidVerifyData1.success).toBe(false); + expect(invalidVerifyData1.error).toBe('Invalid session'); + + verifyResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + data: JSON.stringify({ deviceId, accessToken: 'invalid-access-token' }), + }); + expect(verifyResponse.status()).toBe(401); + const invalidVerifyData2 = await verifyResponse.json().then(({ data }) => data); + expect(invalidVerifyData2.success).toBe(false); + expect(invalidVerifyData2.error).toBe('Invalid session'); + }); + + // TODO: we don't have a way to test this currently since the extension is not installed + test('Web Extension Authentication - Extension not installed', async ({ page, teamCreationUtils1User }) => { + await page.goto(`/web-extension/init/`); + await expect(page.getByText('Authentication in progress...')).toBeVisible(); + }); + + test('Web Extension Authentication - API', async ({ page, teamCreationUtils1User, apiRequestUtils }) => { + const deviceId = uuid(); + const response = await apiRequestUtils.request.post(`/web-extension/session?deviceId=${deviceId}`); + expect(response.status()).toBe(200); + const data = await response.json().then(({ data }) => data); + expect(typeof data.accessToken).toBe('string'); + + let verifyResponse = await apiRequestUtils.request.post(`/web-extension/verify`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + data: JSON.stringify({ deviceId, accessToken: data.accessToken }), + }); + expect(verifyResponse.status()).toBe(200); + const verifyData = await verifyResponse.json().then(({ data }) => data); + expect(verifyData.success).toBe(true); + expect(verifyData.userProfile).toBeDefined(); + + verifyResponse = await apiRequestUtils.request.post(`/web-extension/verify`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + data: JSON.stringify({ deviceId: 'invalid-device-id', accessToken: data.accessToken }), + }); + expect(verifyResponse.status()).toBe(401); + const invalidVerifyData1 = await verifyResponse.json().then(({ data }) => data); + expect(invalidVerifyData1.success).toBe(false); + expect(invalidVerifyData1.error).toBe('Invalid session'); + + verifyResponse = await apiRequestUtils.request.post(`/web-extension/verify`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${data.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + data: JSON.stringify({ deviceId, accessToken: 'invalid-access-token' }), + }); + expect(verifyResponse.status()).toBe(401); + const invalidVerifyData2 = await verifyResponse.json().then(({ data }) => data); + expect(invalidVerifyData2.success).toBe(false); + expect(invalidVerifyData2.error).toBe('Invalid session'); + }); +}); + +test.describe('Desktop / Web-Extension Authentication - Not Logged In', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + test('Desktop Authentication Redirected to Login', async ({ page }) => { + const deviceId = uuid(); + const token = uuid(); + + await page.goto(`/desktop-app/auth/?deviceId=${deviceId}&token=${token}`); + expect(page.url()).toContain('/auth/login/'); + }); + + test('Web Extension - Extension not installed', async ({ page }) => { + await page.goto(`/web-extension/init/`); + expect(page.url()).toContain('/auth/login/'); + }); +}); + +test.describe('Desktop / Web-Extension Authentication - No Access', () => { + test('Desktop Authentication - no subscription', async ({ page }) => { + const deviceId = uuid(); + const token = uuid(); + + await page.goto(`/desktop-app/auth/?deviceId=${deviceId}&token=${token}`); + await expect(page.getByText('You do not have a valid subscription to use the desktop application')).toBeVisible(); + }); + + test('Desktop Authentication - Missing query params', async ({ page }) => { + await page.goto(`/desktop-app/auth/`); + await expect(page.getByText('Error communicating with desktop application, is the application open?')).toBeVisible(); + }); + + // TODO: we don't have a way to test this currently since the extension is not installed + test('Web Extension - Extension not installed', async ({ page }) => { + await page.goto(`/web-extension/init/`); + await expect(page.getByText('Authentication in progress...')).toBeVisible(); + }); +}); diff --git a/apps/landing/hooks/desktop-auth.hooks.ts b/apps/landing/hooks/desktop-auth.hooks.ts index 26c08b884..694a6d6b1 100644 --- a/apps/landing/hooks/desktop-auth.hooks.ts +++ b/apps/landing/hooks/desktop-auth.hooks.ts @@ -1,3 +1,4 @@ +import { HTTP } from '@jetstream/shared/constants'; import type { Maybe } from '@jetstream/types'; import { useSearchParams } from 'next/navigation'; import { useEffect, useReducer } from 'react'; @@ -22,12 +23,16 @@ const ERROR_MAP = { MissingEntitlement: ERROR_MESSAGES.INVALID_SUBSCRIPTION, }; +const STORAGE_KEY = 'desktop-auth-success'; + async function fetchTokens(deviceId: string) { const response = await fetch(`${ENVIRONMENT.SERVER_URL}/desktop-app/auth/session?deviceId=${deviceId}`, { + method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json', + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, }); if (!response.ok) { @@ -101,6 +106,12 @@ export function useDesktopAuthState() { useEffect(() => { try { + // Ensure if the page is refreshed we do not try to re-authenticate + const didSaveAlready = sessionStorage.getItem(STORAGE_KEY) === 'true'; + if (didSaveAlready) { + dispatch({ type: 'SUCCESS' }); + return; + } if (!deviceId || !token) { // For some reason on the initial render, the deviceId and token are not available if (!deviceId && window.location.href.includes('deviceId')) { @@ -115,7 +126,7 @@ export function useDesktopAuthState() { .then((tokens) => { // Provide tokens to the extension window.location.href = `jetstream://auth?deviceId=${deviceId}&token=${token}&accessToken=${tokens.accessToken}`; - // TODO: ideally we could poll server to figure out if the app was able to login successfully + sessionStorage.setItem(STORAGE_KEY, 'true'); dispatch({ type: 'SUCCESS' }); }) .catch((err) => { diff --git a/apps/landing/hooks/web-extension.hooks.ts b/apps/landing/hooks/web-extension.hooks.ts index e71c8698d..5baab3888 100644 --- a/apps/landing/hooks/web-extension.hooks.ts +++ b/apps/landing/hooks/web-extension.hooks.ts @@ -41,6 +41,7 @@ const EVENT_MAP = { async function fetchTokens(deviceId: string) { const response = await fetch(`${ENVIRONMENT.SERVER_URL}/web-extension/session?deviceId=${deviceId}`, { + method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index fe5ab36e4..eb4997855 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -176,6 +176,10 @@ const envSchema = z.object({ } return val || ''; }), + // Should be a base64-encoded 32-byte key (generate with: openssl rand -base64 32) + JWT_ENCRYPTION_KEY: z.string().min(44, { + error: 'JWT_ENCRYPTION_KEY must be a base64-encoded 32-byte key', + }), /** * EMAIL * If not set, email will not be sent diff --git a/libs/auth/server/src/lib/auth-logging.db.service.ts b/libs/auth/server/src/lib/auth-logging.db.service.ts index 7fc4a7aab..9e914eb7d 100644 --- a/libs/auth/server/src/lib/auth-logging.db.service.ts +++ b/libs/auth/server/src/lib/auth-logging.db.service.ts @@ -22,7 +22,9 @@ export type Action = | '2FA_ACTIVATE' | '2FA_DEACTIVATE' | 'REVOKE_SESSION' - | 'DELETE_ACCOUNT'; + | 'DELETE_ACCOUNT' + | 'DESKTOP_LOGIN_TOKEN_ISSUED' + | 'WEB_EXTENSION_LOGIN_TOKEN_ISSUED'; export const actionDisplayName: Record = { LOGIN: 'Login Attempt', @@ -43,6 +45,8 @@ export const actionDisplayName: Record = { '2FA_DEACTIVATE': '2FA Deactivate', REVOKE_SESSION: 'Revoke Session', DELETE_ACCOUNT: 'Delete Account', + DESKTOP_LOGIN_TOKEN_ISSUED: 'Desktop Login Token Issued', + WEB_EXTENSION_LOGIN_TOKEN_ISSUED: 'Web Extension Login Token Issued', }; export const methodDisplayName: Record = { diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index f1220f39f..d523ef0dd 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -122,6 +122,11 @@ export async function pruneExpiredRecords() { expiresAt: { lte: addDays(startOfDay(new Date()), -DELETE_TOKEN_DAYS) }, }, }); + await prisma.webExtensionToken.deleteMany({ + where: { + expiresAt: { lte: addDays(startOfDay(new Date()), -DELETE_TOKEN_DAYS) }, + }, + }); } async function findUserByProviderId(provider: OauthProviderType, providerAccountId: string) { diff --git a/libs/desktop-types/src/lib/desktop-app.types.ts b/libs/desktop-types/src/lib/desktop-app.types.ts index 063673f9a..a4dbc481e 100644 --- a/libs/desktop-types/src/lib/desktop-app.types.ts +++ b/libs/desktop-types/src/lib/desktop-app.types.ts @@ -6,6 +6,7 @@ import { SalesforceOrgUi, SoqlQueryFormatOptionsSchema, UserProfileUi, + UserProfileUiSchema, } from '@jetstream/types'; import { z } from 'zod'; @@ -29,12 +30,12 @@ export const IpcEventChannel = { } as const; export interface ElectronApiCallback { - onAction: (payload: (action: DesktopAction) => void) => void; - onAuthenticate: (payload: (payload: AuthenticatePayload) => void) => void; - onDownloadProgress: (callback: (progress: DownloadZipProgress) => void) => void; - onOrgAdded: (payload: (org: SalesforceOrgUi) => void) => void; - onToastMessage: (callback: (message: { type: InfoSuccessWarningError; message: string; duration?: number }) => void) => void; - onUpdateStatus: (callback: (status: UpdateStatus) => void) => void; + onAction: (payload: (action: DesktopAction) => void) => () => void; + onAuthenticate: (payload: (payload: AuthenticatePayload) => void) => () => void; + onDownloadProgress: (callback: (progress: DownloadZipProgress) => void) => () => void; + onOrgAdded: (payload: (org: SalesforceOrgUi) => void) => () => void; + onToastMessage: (callback: (message: { type: InfoSuccessWarningError; message: string; duration?: number }) => void) => () => void; + onUpdateStatus: (callback: (status: UpdateStatus) => void) => () => void; } export interface ElectronApiRequestResponse { @@ -139,13 +140,7 @@ export const AppDataSchema = z.object({ .optional() .default(() => crypto.randomUUID()), accessToken: z.string().nullish(), - userProfile: z - .looseObject({ - id: z.string(), - name: z.string(), - email: z.string(), - }) - .nullish(), + userProfile: UserProfileUiSchema.nullish(), expiresAt: z.number().nullish(), lastChecked: z.number().nullish(), }); diff --git a/libs/shared/constants/src/lib/shared-constants.ts b/libs/shared/constants/src/lib/shared-constants.ts index ea26cc782..34c8234fb 100644 --- a/libs/shared/constants/src/lib/shared-constants.ts +++ b/libs/shared/constants/src/lib/shared-constants.ts @@ -62,6 +62,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', CONTENT_TYPE: 'Content-Type', X_MOCK_KEY: 'X-MOCK-KEY', X_FORWARDED_FOR: 'X-FORWARDED-FOR', diff --git a/libs/shared/ui-core/src/app/HeaderNavbar.tsx b/libs/shared/ui-core/src/app/HeaderNavbar.tsx index 7b0ef888d..1cda6f128 100644 --- a/libs/shared/ui-core/src/app/HeaderNavbar.tsx +++ b/libs/shared/ui-core/src/app/HeaderNavbar.tsx @@ -111,9 +111,14 @@ export const HeaderNavbar = ({ case 'profile': navigate(APP_ROUTES.PROFILE.ROUTE); break; - case 'team-dashboard': - navigate(APP_ROUTES.TEAM_DASHBOARD.ROUTE); + case 'team-dashboard': { + if (isDesktop) { + window.open(`${applicationState.serverUrl}/app/teams`, '_blank'); + } else { + navigate(APP_ROUTES.TEAM_DASHBOARD.ROUTE); + } break; + } case 'billing': navigate(APP_ROUTES.BILLING.ROUTE); break; diff --git a/prisma/migrations/20251228000000_add_token_hash_to_web_extension_token/migration.sql b/prisma/migrations/20251228000000_add_token_hash_to_web_extension_token/migration.sql new file mode 100644 index 000000000..dc390a663 --- /dev/null +++ b/prisma/migrations/20251228000000_add_token_hash_to_web_extension_token/migration.sql @@ -0,0 +1,19 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- AlterTable +ALTER TABLE "web_extension_token" ADD COLUMN "tokenHash" VARCHAR(64); + +-- Populate tokenHash for existing records +-- Encryption will happen on-the-fly as they are accessed and we may do a manual encryption migration later if needed +UPDATE "web_extension_token" +SET "tokenHash" = encode(digest("token", 'sha256'), 'hex') +WHERE token IS NOT NULL; + +-- Make tokenHash NOT NULL +ALTER TABLE "web_extension_token" ALTER COLUMN "tokenHash" SET NOT NULL; + +-- DropIndex (drop old unique constraint on token) +DROP INDEX IF EXISTS "web_extension_token_type_token_deviceId_key"; + +-- CreateIndex (add new unique constraint on tokenHash) +CREATE UNIQUE INDEX "web_extension_token_type_tokenHash_deviceId_key" ON "web_extension_token"("type", "tokenHash", "deviceId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 01dd1788c..c2e1b62f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -251,6 +251,7 @@ model WebExtensionToken { type String source String @default("BROWSER_EXTENSION") token String + tokenHash String @db.VarChar(64) deviceId String @unique ipAddress String userAgent String @@ -261,7 +262,7 @@ model WebExtensionToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([type, userId, deviceId]) - @@unique([type, token, deviceId]) + @@unique([type, tokenHash, deviceId]) @@map("web_extension_token") } diff --git a/scripts/generate.env.mjs b/scripts/generate.env.mjs index 96f4074f1..22dafb18e 100644 --- a/scripts/generate.env.mjs +++ b/scripts/generate.env.mjs @@ -45,6 +45,7 @@ const replacements = [ ['JETSTREAM_AUTH_OTP_SECRET=', `JETSTREAM_AUTH_OTP_SECRET='${generateRandomBase64(32)}'`], ['EXAMPLE_USER_OVERRIDE=', `EXAMPLE_USER_OVERRIDE='${enableExampleUser}'`], ['SFDC_ENCRYPTION_KEY=', `SFDC_ENCRYPTION_KEY='${generateRandomBase64(32)}'`], + ['JWT_ENCRYPTION_KEY=', `JWT_ENCRYPTION_KEY='${generateRandomBase64(32)}'`], ]; // for each line in file, see if line starts with a replacement string From 36edb1e4783bb71f259d70878fcede5d0f404227 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Mon, 29 Dec 2025 09:17:13 -0600 Subject: [PATCH 3/4] refactor: use auth header for external clients Ensure all external clients (desktop/web extension) utilize authorization header instead of the body Update external auth to pull from body as a fallback for backwards compatibility Fix logout response handling --- .../app/controllers/desktop-app.controller.ts | 77 +++++++++++-------- .../controllers/web-extension.controller.ts | 73 ++++++++++-------- apps/api/src/app/db/web-extension.db.ts | 5 +- apps/api/src/app/routes/desktop-app.routes.ts | 25 ++++-- apps/api/src/app/routes/openapi.routes.ts | 8 +- apps/api/src/app/routes/route.middleware.ts | 5 ++ .../app/routes/web-extension-server.routes.ts | 46 ++++++++--- .../src/app/services/external-auth.service.ts | 37 +++++++-- apps/api/src/app/utils/response.handlers.ts | 13 +++- .../jetstream-data-sync.desktop.controller.ts | 3 + .../controllers/user.desktop.controller.ts | 2 + .../src/services/api.service.ts | 19 +++-- .../src/services/ipc.service.ts | 11 ++- .../src/services/protocol.service.ts | 1 + .../external-auth-logged-in.spec.ts | 73 +++++++++++------- .../jetstream-data-sync.web-ext.controller.ts | 2 + .../controllers/user.web-ext.controller.ts | 1 + .../src/core/GlobalExtensionLoggedOut.tsx | 2 +- .../src/extension-scripts/service-worker.ts | 35 ++++++--- .../src/pages/popup/Popup.tsx | 2 +- apps/landing/hooks/desktop-auth.hooks.ts | 4 +- apps/landing/hooks/web-extension.hooks.ts | 4 +- .../web-extension/{init => auth}/index.tsx | 0 libs/api-config/src/lib/api-logger.ts | 9 ++- libs/api-types/src/lib/api-route.types.ts | 4 + .../server/src/lib/auth-logging.db.service.ts | 6 +- libs/auth/types/src/lib/auth-types.globals.ts | 3 +- 27 files changed, 315 insertions(+), 155 deletions(-) rename apps/landing/pages/web-extension/{init => auth}/index.tsx (100%) diff --git a/apps/api/src/app/controllers/desktop-app.controller.ts b/apps/api/src/app/controllers/desktop-app.controller.ts index 2c99887fe..98eb27980 100644 --- a/apps/api/src/app/controllers/desktop-app.controller.ts +++ b/apps/api/src/app/controllers/desktop-app.controller.ts @@ -34,10 +34,16 @@ export const routeDefinition = { controllerFn: () => logout, responseType: z.object({ success: z.boolean(), error: z.string().nullish() }), validators: { - body: z.object({ - deviceId: z.string(), - accessToken: z.string(), - }), + /** + * @deprecated, prefer headers for passing deviceId and accessToken + * For backwards compatibility, auth checks attempt to pull from body if headers are not present + */ + body: z + .object({ + deviceId: z.string().optional(), + accessToken: z.string().optional(), + }) + .optional(), hasSourceOrg: false, }, }, @@ -45,9 +51,6 @@ export const routeDefinition = { controllerFn: () => initSession, responseType: z.object({ accessToken: z.string() }), validators: { - query: z.object({ - deviceId: z.uuid(), - }), hasSourceOrg: false, }, }, @@ -55,10 +58,16 @@ export const routeDefinition = { controllerFn: () => verifyToken, responseType: z.object({ success: z.boolean(), error: z.string().nullish(), userProfile: UserProfileUiSchema.optional() }), validators: { - body: z.object({ - deviceId: z.string(), - accessToken: z.string(), - }), + /** + * @deprecated, prefer headers for passing deviceId and accessToken + * For backwards compatibility, auth checks attempt to pull from body if headers are not present + */ + body: z + .object({ + deviceId: z.string().optional(), + accessToken: z.string().optional(), + }) + .optional(), hasSourceOrg: false, }, }, @@ -106,10 +115,10 @@ const initAuthMiddleware = createRoute(routeDefinition.initAuthMiddleware.valida * This issues access tokens or returns existing access tokens * This route is called after the user is already authenticated through normal means */ -const initSession = createRoute(routeDefinition.initSession.validators, async ({ query, user }, req, res, next) => { - const { deviceId } = query; +const initSession = createRoute(routeDefinition.initSession.validators, async ({ user }, req, res, next) => { + const { deviceId } = res.locals; - if (!req.session.user) { + if (!req.session.user || !deviceId) { next(new InvalidSession()); return; } @@ -138,6 +147,10 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ res.log.info({ userId: user.id, deviceId, expiresAt }, 'Reusing existing desktop token'); sendJson(res, { accessToken: decryptedToken }); + createUserActivityFromReq(req, res, { + action: 'DESKTOP_LOGIN_TOKEN_REUSED', + success: true, + }); return; } @@ -163,35 +176,35 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ }); }); -const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ body }, _, res) => { +const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, _, res) => { + const { deviceId } = res.locals; try { - const { accessToken, deviceId } = body; - // This validates the token against the database record - const { userProfile: userProfileJwt } = await externalAuthService.verifyToken( - { token: accessToken, deviceId }, - externalAuthService.AUDIENCE_DESKTOP, - ); - const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: userProfileJwt.id, omitSubscriptions: true }); + 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'); sendJson(res, { success: true, userProfile }); } catch (ex) { - res.log.error({ deviceId: body?.deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error verifying Desktop App token'); + 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 ({ body }, _, res) => { +const logout = createRoute(routeDefinition.logout.validators, async ({ user }, _, res) => { + const { deviceId } = res.locals; try { - const { accessToken, deviceId } = body; - // This validates the token against the database record - const { userProfile } = await externalAuthService.verifyToken({ token: accessToken, deviceId }, externalAuthService.AUDIENCE_DESKTOP); - webExtDb.deleteByUserIdAndDeviceId({ userId: userProfile.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH }); - res.log.info({ userId: userProfile.id, deviceId }, 'User logged out of desktop app'); + if (!deviceId || !user) { + throw new InvalidSession(); + } + await webExtDb.deleteByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH }); + res.log.info({ userId: user.id, deviceId }, 'User logged out of desktop app'); sendJson(res, { success: true }); } catch (ex) { - res.log.error({ deviceId: body?.deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error logging out of desktop app'); + res.log.error({ userId: user?.id, deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error logging out of desktop app'); sendJson(res, { success: false, error: 'Invalid session' }, 401); } }); @@ -231,10 +244,10 @@ const dataSyncPush = createRoute(routeDefinition.dataSyncPush.validators, async sendJson(res, response); }); -const notifications = createRoute(routeDefinition.notifications.validators, async ({ query }, req, res) => { +const notifications = createRoute(routeDefinition.notifications.validators, async ({ query, user }, req, res) => { // TODO: reserved for future use (e.g. check if there is a critical update required, or auto-update is broken etc..) const { os, version } = query; - const { deviceId, user } = await externalAuthService.getUserAndDeviceIdForExternalAuth(externalAuthService.AUDIENCE_DESKTOP, req); + const { deviceId } = res.locals; // TODO: potentially message user based on some conditions diff --git a/apps/api/src/app/controllers/web-extension.controller.ts b/apps/api/src/app/controllers/web-extension.controller.ts index eb5cee760..e9801ab6f 100644 --- a/apps/api/src/app/controllers/web-extension.controller.ts +++ b/apps/api/src/app/controllers/web-extension.controller.ts @@ -33,10 +33,16 @@ export const routeDefinition = { controllerFn: () => logout, responseType: z.object({ success: z.boolean(), error: z.string().nullish() }), validators: { - body: z.object({ - deviceId: z.string(), - accessToken: z.string(), - }), + /** + * @deprecated, prefer headers for passing deviceId and accessToken + * For backwards compatibility, auth checks attempt to pull from body if headers are not present + */ + body: z + .object({ + deviceId: z.string().optional(), + accessToken: z.string().optional(), + }) + .optional(), hasSourceOrg: false, }, }, @@ -44,9 +50,6 @@ export const routeDefinition = { controllerFn: () => initSession, responseType: z.object({ accessToken: z.string() }), validators: { - query: z.object({ - deviceId: z.uuid(), - }), hasSourceOrg: false, }, }, @@ -54,10 +57,16 @@ export const routeDefinition = { controllerFn: () => verifyToken, responseType: z.object({ success: z.boolean(), error: z.string().nullish() }), validators: { - body: z.object({ - deviceId: z.string(), - accessToken: z.string(), - }), + /** + * @deprecated, prefer headers for passing deviceId and accessToken + * For backwards compatibility, auth checks attempt to pull from body if headers are not present + */ + body: z + .object({ + deviceId: z.string().optional(), + accessToken: z.string().optional(), + }) + .optional(), hasSourceOrg: false, }, }, @@ -85,7 +94,7 @@ const initAuthMiddleware = createRoute(routeDefinition.initAuthMiddleware.valida // redirect to login flow if user is not signed in if (!req.session.user) { const { redirectUrl: redirectUrlCookie } = getCookieConfig(ENV.USE_SECURE_COOKIES); - setCookie(redirectUrlCookie.name, `${ENV.JETSTREAM_SERVER_URL}/web-extension/init`, redirectUrlCookie.options); + setCookie(redirectUrlCookie.name, `${ENV.JETSTREAM_SERVER_URL}/web-extension/auth`, redirectUrlCookie.options); redirect(res, '/auth/login/'); return; } @@ -96,10 +105,10 @@ const initAuthMiddleware = createRoute(routeDefinition.initAuthMiddleware.valida * This issues access tokens or returns existing access tokens * This route is called after the user is already authenticated through normal means */ -const initSession = createRoute(routeDefinition.initSession.validators, async ({ query, user }, req, res, next) => { - const { deviceId } = query; +const initSession = createRoute(routeDefinition.initSession.validators, async ({ user }, req, res, next) => { + const { deviceId } = res.locals; - if (!req.session.user) { + if (!req.session.user || !deviceId) { next(new InvalidSession()); return; } @@ -126,6 +135,10 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ res.log.info({ userId: user.id, deviceId, expiresAt }, 'Reusing existing web extension token'); sendJson(res, { accessToken: decryptedToken }); + createUserActivityFromReq(req, res, { + action: 'WEB_EXTENSION_LOGIN_TOKEN_REUSED', + success: true, + }); return; } @@ -149,35 +162,35 @@ const initSession = createRoute(routeDefinition.initSession.validators, async ({ }); }); -const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ body }, _, res) => { +const verifyToken = createRoute(routeDefinition.verifyToken.validators, async ({ user }, _, res) => { + const { deviceId } = res.locals; try { - const { accessToken, deviceId } = body; - // This validates the token against the database record - const { userProfile: userProfileJwt } = await externalAuthService.verifyToken( - { token: accessToken, deviceId }, - externalAuthService.AUDIENCE_WEB_EXT, - ); - const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: userProfileJwt.id, omitSubscriptions: true }); + 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 }); } catch (ex) { - res.log.error({ deviceId: body?.deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error verifying web extension token'); + 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 ({ body }, _, res) => { +const logout = createRoute(routeDefinition.logout.validators, async ({ user }, _, res) => { + const { deviceId } = res.locals; try { - const { accessToken, deviceId } = body; + if (!deviceId || !user) { + throw new InvalidSession(); + } // This validates the token against the database record - const { userProfile } = await externalAuthService.verifyToken({ token: accessToken, deviceId }, externalAuthService.AUDIENCE_WEB_EXT); - webExtDb.deleteByUserIdAndDeviceId({ userId: userProfile.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH }); - res.log.info({ userId: userProfile.id, deviceId }, 'User logged out of browser extension'); + await webExtDb.deleteByUserIdAndDeviceId({ userId: user.id, deviceId, type: webExtDb.TOKEN_TYPE_AUTH }); + res.log.info({ userId: user.id, deviceId }, 'User logged out of browser extension'); sendJson(res, { success: true }); } catch (ex) { - res.log.error({ deviceId: body?.deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error logging out of browser extension'); + res.log.error({ userId: user?.id, deviceId, ...getErrorMessageAndStackObj(ex) }, 'Error logging out of browser extension'); sendJson(res, { success: false, error: 'Invalid session' }, 401); } }); diff --git a/apps/api/src/app/db/web-extension.db.ts b/apps/api/src/app/db/web-extension.db.ts index a2ab2b98e..018cfea4d 100644 --- a/apps/api/src/app/db/web-extension.db.ts +++ b/apps/api/src/app/db/web-extension.db.ts @@ -103,11 +103,12 @@ export const findByAccessTokenAndDeviceId = async ({ token, deviceId, type }: { }); // TEMPORARY: This is here to prevent breaking changes during migration + // Ticket to resolve: #1494 // After all tokens have been encrypted, we can remove this block // Auto-upgrade legacy tokens to encrypted format if (record && !isTokenEncrypted(record.token)) { - upgradeLegacyToken(record.id, token).catch((err) => { - logger.error('Failed to upgrade legacy token:', err); + await upgradeLegacyToken(record.id, token).catch((err) => { + logger.error({ error: getErrorMessage(err) }, 'Failed to upgrade legacy token'); }); } diff --git a/apps/api/src/app/routes/desktop-app.routes.ts b/apps/api/src/app/routes/desktop-app.routes.ts index 37ac8074c..5acb2ce85 100644 --- a/apps/api/src/app/routes/desktop-app.routes.ts +++ b/apps/api/src/app/routes/desktop-app.routes.ts @@ -4,7 +4,7 @@ import helmet from 'helmet'; import * as desktopAppController from '../controllers/desktop-app.controller'; import * as userFeedbackController from '../controllers/user-feedback.controller'; import * as externalAuthService from '../services/external-auth.service'; -import { feedbackRateLimit, feedbackUploadMiddleware } from './route.middleware'; +import { deprecatedRouteMiddleware, feedbackRateLimit, feedbackUploadMiddleware } from './route.middleware'; function getMaxRequests(value: number) { return ENV.CI || ENV.ENVIRONMENT === 'development' ? 10000 : value; @@ -48,6 +48,8 @@ routes.use( }), ); +routes.use(externalAuthService.addDeviceIdToLocals); + const authMiddleware = externalAuthService.getExternalAuthMiddleware(externalAuthService.AUDIENCE_DESKTOP); /** @@ -56,15 +58,22 @@ const authMiddleware = externalAuthService.getExternalAuthMiddleware(externalAut // NOTE: MIDDLEWARE ROUTE - will either redirect to login or will call next() to allow static page to be served routes.get('/auth', LAX_AuthRateLimit, desktopAppController.routeDefinition.initAuthMiddleware.controllerFn()); -/** - * @deprecated - use POST instead - */ -routes.get('/auth/session', STRICT_2X_AuthRateLimit, desktopAppController.routeDefinition.initSession.controllerFn()); // API endpoint that /auth/desktop calls to get tokens to avoid having them defined in the HTML directly - this endpoint issues tokens routes.post('/auth/session', STRICT_2X_AuthRateLimit, desktopAppController.routeDefinition.initSession.controllerFn()); // Validate authentication status from desktop app -routes.post('/auth/verify', STRICT_AuthRateLimit, desktopAppController.routeDefinition.verifyToken.controllerFn()); -routes.delete('/auth/logout', STRICT_AuthRateLimit, desktopAppController.routeDefinition.logout.controllerFn()); +routes.post('/auth/verify', STRICT_AuthRateLimit, authMiddleware, desktopAppController.routeDefinition.verifyToken.controllerFn()); +/** + * @deprecated - use /auth/logout instead + * Kept for backward compatibility as clients may be on older versions + */ +routes.delete( + '/logout', + STRICT_AuthRateLimit, + deprecatedRouteMiddleware, + authMiddleware, + desktopAppController.routeDefinition.logout.controllerFn(), +); +routes.delete('/auth/logout', STRICT_AuthRateLimit, authMiddleware, desktopAppController.routeDefinition.logout.controllerFn()); /** * Other Routes @@ -72,7 +81,7 @@ routes.delete('/auth/logout', STRICT_AuthRateLimit, desktopAppController.routeDe routes.get('/data-sync/pull', authMiddleware, desktopAppController.routeDefinition.dataSyncPull.controllerFn()); routes.post('/data-sync/push', authMiddleware, desktopAppController.routeDefinition.dataSyncPush.controllerFn()); -routes.get('/v1/notifications', STRICT_2X_AuthRateLimit, desktopAppController.routeDefinition.notifications.controllerFn()); +routes.get('/v1/notifications', STRICT_2X_AuthRateLimit, authMiddleware, desktopAppController.routeDefinition.notifications.controllerFn()); routes.post( '/feedback', diff --git a/apps/api/src/app/routes/openapi.routes.ts b/apps/api/src/app/routes/openapi.routes.ts index 393bb93a6..6b5a21d1f 100644 --- a/apps/api/src/app/routes/openapi.routes.ts +++ b/apps/api/src/app/routes/openapi.routes.ts @@ -567,7 +567,6 @@ export function getOpenApiSpec() { // Desktop App Controller Routes (prefix: /desktop-app) '/desktop-app/auth/session': { - get: { ...getRequest({ ...desktopController.initSession.validators, tags: ['desktop'] }), deprecated: true }, post: { ...getRequest({ ...desktopController.initSession.validators, tags: ['desktop'] }) }, }, '/desktop-app/auth/verify': { @@ -587,14 +586,13 @@ export function getOpenApiSpec() { }, // Web Extension Controller Routes (prefix: /web-extension) - '/web-extension/session': { - get: { ...getRequest({ ...webExtensionController.initSession.validators, tags: ['webExtension'] }), deprecated: true }, + '/web-extension/auth/session': { post: { ...getRequest({ ...webExtensionController.initSession.validators, tags: ['webExtension'] }) }, }, - '/web-extension/verify': { + '/web-extension/auth/verify': { post: { ...getRequest({ ...webExtensionController.verifyToken.validators, tags: ['webExtension'] }) }, }, - '/web-extension/logout': { + '/web-extension/auth/logout': { delete: { ...getRequest({ ...webExtensionController.logout.validators, tags: ['webExtension'] }) }, }, '/web-extension/data-sync/pull': { diff --git a/apps/api/src/app/routes/route.middleware.ts b/apps/api/src/app/routes/route.middleware.ts index 7e479c978..4f3b3164c 100644 --- a/apps/api/src/app/routes/route.middleware.ts +++ b/apps/api/src/app/routes/route.middleware.ts @@ -87,6 +87,11 @@ export function setApplicationCookieMiddleware(_: express.Request, res: express. next(); } +export function deprecatedRouteMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { + res.log.warn({ path: req.path, method: req.method }, '[DEPRECATED][ROUTE] This route is deprecated'); + next(); +} + export function notFoundMiddleware(_: express.Request, __: express.Response, next: express.NextFunction) { const error = new NotFoundError('Route not found'); next(error); diff --git a/apps/api/src/app/routes/web-extension-server.routes.ts b/apps/api/src/app/routes/web-extension-server.routes.ts index 428bfdca5..3a541b97a 100644 --- a/apps/api/src/app/routes/web-extension-server.routes.ts +++ b/apps/api/src/app/routes/web-extension-server.routes.ts @@ -4,7 +4,7 @@ import helmet from 'helmet'; import * as userFeedbackController from '../controllers/user-feedback.controller'; import * as webExtensionController from '../controllers/web-extension.controller'; import * as externalAuthService from '../services/external-auth.service'; -import { feedbackRateLimit, feedbackUploadMiddleware } from './route.middleware'; +import { deprecatedRouteMiddleware, feedbackRateLimit, feedbackUploadMiddleware } from './route.middleware'; function getMaxRequests(value: number) { return ENV.CI || ENV.ENVIRONMENT === 'development' ? 10000 : value; @@ -48,23 +48,51 @@ routes.use( }), ); +routes.use(externalAuthService.addDeviceIdToLocals); + const authMiddleware = externalAuthService.getExternalAuthMiddleware(externalAuthService.AUDIENCE_WEB_EXT); /** * Authentication routes */ +/** + * @deprecated - redirect to new route - /auth + */ +routes.get('/init', (req, res) => { + const queryString = new URLSearchParams(req.query as Record).toString(); + res.redirect(301, `/web-extension/auth${queryString ? `?${queryString}` : ''}`); +}); // NOTE: MIDDLEWARE ROUTE - will either redirect to login or will call next() to allow static page to be served -routes.get('/init', LAX_AuthRateLimit, webExtensionController.routeDefinition.initAuthMiddleware.controllerFn()); +routes.get('/auth', LAX_AuthRateLimit, webExtensionController.routeDefinition.initAuthMiddleware.controllerFn()); +// API endpoint that /auth calls to get tokens to avoid having them defined in the HTML directly - this endpoint issues tokens +routes.post('/auth/session', STRICT_2X_AuthRateLimit, webExtensionController.routeDefinition.initSession.controllerFn()); +// Validate authentication status from browser extension /** - * @deprecated - use POST instead + * @deprecated - use /auth/verify instead + * Kept for backward compatibility as clients may be on older versions */ -routes.get('/session', STRICT_2X_AuthRateLimit, webExtensionController.routeDefinition.initSession.controllerFn()); -// API endpoint that /init calls to get tokens to avoid having them defined in the HTML directly - this endpoint issues tokens -routes.post('/session', STRICT_2X_AuthRateLimit, webExtensionController.routeDefinition.initSession.controllerFn()); -// Validate authentication status from browser extension -routes.post('/verify', STRICT_AuthRateLimit, webExtensionController.routeDefinition.verifyToken.controllerFn()); -routes.delete('/logout', STRICT_AuthRateLimit, webExtensionController.routeDefinition.logout.controllerFn()); +routes.post( + '/verify', + STRICT_AuthRateLimit, + deprecatedRouteMiddleware, + authMiddleware, + webExtensionController.routeDefinition.verifyToken.controllerFn(), +); +routes.post('/auth/verify', STRICT_AuthRateLimit, authMiddleware, webExtensionController.routeDefinition.verifyToken.controllerFn()); + +/** + * @deprecated - use /auth/logout instead + * Kept for backward compatibility as clients may be on older versions + */ +routes.delete( + '/logout', + STRICT_AuthRateLimit, + deprecatedRouteMiddleware, + authMiddleware, + webExtensionController.routeDefinition.logout.controllerFn(), +); +routes.delete('/auth/logout', STRICT_AuthRateLimit, authMiddleware, webExtensionController.routeDefinition.logout.controllerFn()); /** * Other Routes diff --git a/apps/api/src/app/services/external-auth.service.ts b/apps/api/src/app/services/external-auth.service.ts index 0c537f6ac..7060a0e75 100644 --- a/apps/api/src/app/services/external-auth.service.ts +++ b/apps/api/src/app/services/external-auth.service.ts @@ -92,11 +92,16 @@ export async function verifyToken( return (await jwtVerifier(token)) as JwtDecodedPayload; } -export async function getUserAndDeviceIdForExternalAuth(audience: Audience, req: express.Request) { - let deviceId: Maybe = null; +export async function getUserAndDeviceIdForExternalAuth( + audience: Audience, + req: express.Request, + res: express.Response, +) { + const deviceId = getDeviceId(req, res); try { - const accessToken = req.get('Authorization')?.split(' ')[1]; - deviceId = req.get(HTTP.HEADERS.X_EXT_DEVICE_ID) || req.get(HTTP.HEADERS.X_WEB_EXTENSION_DEVICE_ID); + // Some prior endpoints may have accessToken in the body instead of Authorization header + const accessToken = req.get('Authorization')?.split(' ')[1] || (req.body as Maybe<{ accessToken?: string }>)?.accessToken; + let user: UserProfileSession | null = null; if (accessToken && deviceId) { const cacheKey = `${accessToken}-${deviceId}`; @@ -116,10 +121,29 @@ export async function getUserAndDeviceIdForExternalAuth(audience: Audience, req: } } +export function getDeviceId(req: express.Request, res: express.Response) { + try { + const deviceId = + res.locals.deviceId || + req.get(HTTP.HEADERS.X_EXT_DEVICE_ID) || + req.get(HTTP.HEADERS.X_WEB_EXTENSION_DEVICE_ID) || + (req.body as Maybe<{ deviceId?: string }>)?.deviceId || + (req.query as Maybe<{ deviceId?: string }>)?.deviceId; + return deviceId as Maybe; + } catch { + return null; + } +} + +export function addDeviceIdToLocals(req: express.Request, res: express.Response, next: express.NextFunction) { + res.locals.deviceId = getDeviceId(req, res); + next(); +} + export function getExternalAuthMiddleware(audience: Audience) { - return async (req: express.Request, _: express.Response, next: express.NextFunction) => { + return async (req: express.Request, res: express.Response, next: express.NextFunction) => { try { - const { deviceId, user } = await getUserAndDeviceIdForExternalAuth(audience, req); + const { deviceId, user } = await getUserAndDeviceIdForExternalAuth(audience, req, res); if (!user) { throw new AuthenticationError('Unauthorized', { skipLogout: true }); } @@ -127,6 +151,7 @@ export function getExternalAuthMiddleware(audience: Audience) { deviceId, user, }; + res.locals.deviceId = deviceId; next(); } catch (ex) { req.log.info('[DESKTOP-AUTH][AUTH ERROR] Error decoding token', ex); diff --git a/apps/api/src/app/utils/response.handlers.ts b/apps/api/src/app/utils/response.handlers.ts index 44c90b6af..9cded00d9 100644 --- a/apps/api/src/app/utils/response.handlers.ts +++ b/apps/api/src/app/utils/response.handlers.ts @@ -21,6 +21,7 @@ export async function healthCheck(_: express.Request, res: express.Response) { } catch (ex) { res.status(500).json({ error: true, + success: false, uptime: process.uptime(), message: `Unhealthy: ${ex.message}`, }); @@ -118,7 +119,7 @@ export function streamParsedCsvAsJson(res: express.Response, csvParseStream: Dup csvParseStream.on('error', (err) => { res.log.warn({ requestId: res.locals.requestId, ...getExceptionLog(err) }, 'Error streaming CSV.'); if (!res.headersSent) { - res.status(400).json({ error: true, message: 'Error streaming CSV' }); + res.status(400).json({ error: true, success: false, message: 'Error streaming CSV' }); } else { res.status(400).end(); } @@ -199,9 +200,11 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: if (isJson) { return res.json({ error: true, + success: false, errorType: err.type, data: { error: true, + success: false, errorType: err.type, }, }); @@ -215,6 +218,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: responseLogger.debug({ ...getExceptionLog(err, true), statusCode }, '[RESPONSE][ERROR]'); return res.json({ error: true, + success: false, message: err.message, data: err.additionalData, }); @@ -224,10 +228,11 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: res.status(status || 400); return res.json({ error: true, + success: false, message, }); } else if (err instanceof AuthenticationError) { - // This error is emitted when a user attempts to make a request taht requires authentication, but the user is not logged in + // This error is emitted when a user attempts to make a request that requires authentication, but the user is not logged in responseLogger.warn({ ...getExceptionLog(err), statusCode: 401 }, '[RESPONSE][ERROR]'); res.status(status || 401); if (!err.skipLogout) { @@ -241,6 +246,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: if (isJson) { return res.json({ error: true, + success: false, message: err.message, data: err.additionalData, }); @@ -256,6 +262,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: if (isJson) { return res.json({ error: true, + success: false, message: err.message, data: err.additionalData, }); @@ -298,7 +305,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: logger.error(getExceptionLog(rollbarEx), 'Error sending to Rollbar'); } logger.error(getExceptionLog(ex, true), 'Error in uncaughtErrorHandler'); - res.status(500).json({ error: true, message: 'Internal Server Error' }); + res.status(500).json({ error: true, success: false, message: 'Internal Server Error' }); } } diff --git a/apps/jetstream-desktop/src/controllers/jetstream-data-sync.desktop.controller.ts b/apps/jetstream-desktop/src/controllers/jetstream-data-sync.desktop.controller.ts index 91aa04e6b..83cda8b25 100644 --- a/apps/jetstream-desktop/src/controllers/jetstream-data-sync.desktop.controller.ts +++ b/apps/jetstream-desktop/src/controllers/jetstream-data-sync.desktop.controller.ts @@ -1,4 +1,5 @@ import { HTTP } from '@jetstream/shared/constants'; +import { app } from 'electron'; import { z } from 'zod'; import { ENV } from '../config/environment'; import { getAppData } from '../services/persistence.service'; @@ -46,6 +47,7 @@ const pull = createRoute(routeDefinition.pull.validators, async ({ query }) => { Accept: 'application/json', Authorization: `Bearer ${authTokens?.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), }, }); } catch (ex) { @@ -64,6 +66,7 @@ const push = createRoute(routeDefinition.push.validators, async ({ query, body } 'Content-Type': 'application/json', Authorization: `Bearer ${authTokens?.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), }, body: JSON.stringify(body), }); diff --git a/apps/jetstream-desktop/src/controllers/user.desktop.controller.ts b/apps/jetstream-desktop/src/controllers/user.desktop.controller.ts index 558609d50..478a0e24f 100644 --- a/apps/jetstream-desktop/src/controllers/user.desktop.controller.ts +++ b/apps/jetstream-desktop/src/controllers/user.desktop.controller.ts @@ -1,5 +1,6 @@ import { DesktopUserPreferencesSchema } from '@jetstream/desktop/types'; import { HTTP } from '@jetstream/shared/constants'; +import { app } from 'electron'; import { z } from 'zod'; import { ENV } from '../config/environment'; import * as dataService from '../services/persistence.service'; @@ -91,6 +92,7 @@ const sendUserFeedbackEmail = createRoute(routeDefinition.sendUserFeedbackEmail. 'Content-Type': req.request.headers.get('content-type')!, Authorization: `Bearer ${authTokens?.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), }, body, }); diff --git a/apps/jetstream-desktop/src/services/api.service.ts b/apps/jetstream-desktop/src/services/api.service.ts index 3f87214df..ead0ef6cb 100644 --- a/apps/jetstream-desktop/src/services/api.service.ts +++ b/apps/jetstream-desktop/src/services/api.service.ts @@ -9,20 +9,20 @@ import { ENV } from '../config/environment'; const AuthResponseSuccessSchema = z.object({ success: z.literal(true), userProfile: UserProfileUiSchema }); const AuthResponseErrorSchema = z.object({ success: z.literal(false), error: z.string() }); const SuccessOrErrorSchema = z.union([AuthResponseSuccessSchema, AuthResponseErrorSchema]); +const SuccessWithoutUserProfileOrErrorSchema = z.union([AuthResponseSuccessSchema.omit({ userProfile: true }), AuthResponseErrorSchema]); export type AuthResponseSuccess = z.infer; export type AuthResponseError = z.infer; -export async function verifyAuthToken(payload: { deviceId: string; accessToken: string }) { +export async function verifyAuthToken({ accessToken, deviceId }: { deviceId: string; accessToken: string }) { const response = await net.fetch(`${ENV.SERVER_URL}/desktop-app/auth/verify`, { method: 'POST', headers: { - 'Content-Type': 'application/json', Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), - [HTTP.HEADERS.X_EXT_DEVICE_ID]: payload.deviceId, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, - body: JSON.stringify(payload), }); const results = SuccessOrErrorSchema.safeParse( @@ -40,19 +40,18 @@ export async function verifyAuthToken(payload: { deviceId: string; accessToken: return results.data; } -export async function logout(payload: { deviceId: string; accessToken: string }) { - const response = await net.fetch(`${ENV.SERVER_URL}/desktop-app/logout`, { +export async function logout({ accessToken, deviceId }: { deviceId: string; accessToken: string }) { + const response = await net.fetch(`${ENV.SERVER_URL}/desktop-app/auth/logout`, { method: 'DELETE', headers: { - 'Content-Type': 'application/json', Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, [HTTP.HEADERS.X_APP_VERSION]: app.getVersion(), - [HTTP.HEADERS.X_EXT_DEVICE_ID]: payload.deviceId, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, - body: JSON.stringify(payload), }); - const results = SuccessOrErrorSchema.safeParse( + const results = SuccessWithoutUserProfileOrErrorSchema.safeParse( await response .json() .then((value) => value?.data) diff --git a/apps/jetstream-desktop/src/services/ipc.service.ts b/apps/jetstream-desktop/src/services/ipc.service.ts index e51b451a8..3c535319a 100644 --- a/apps/jetstream-desktop/src/services/ipc.service.ts +++ b/apps/jetstream-desktop/src/services/ipc.service.ts @@ -12,7 +12,7 @@ import { ApiConnection, BinaryFileDownload, getApiRequestFactoryFn, getBinaryFil import * as oauthService from '@jetstream/salesforce-oauth'; import { HTTP } from '@jetstream/shared/constants'; import { UserProfileUi } from '@jetstream/types'; -import { addDays } from 'date-fns'; +import { addHours } from 'date-fns'; import { app, dialog, ipcMain, shell } from 'electron'; import logger from 'electron-log'; import { ResponseBodyError } from 'oauth4webapi'; @@ -161,7 +161,7 @@ const handleLogoutEvent: MainIpcHandler<'logout'> = async () => { const { deviceId, accessToken } = appData; if (deviceId && accessToken) { - logout({ deviceId, accessToken }); + await logout({ deviceId, accessToken }); } dataService.setAppData({ @@ -247,13 +247,14 @@ const handleAddOrgEvent: MainIpcHandler<'addOrg'> = async (event, payload) => { const handleCheckAuthEvent: MainIpcHandler<'checkAuth'> = async (): Promise< { userProfile: UserProfileUi; authInfo: DesktopAuthInfo } | undefined > => { - const AUTH_CHECK_INTERVAL_DAYS = 1; + // Check auth occasionally to ensure token is still valid + const AUTH_CHECK_INTERVAL_HOURS = 3; const appData = dataService.getAppData(); const userProfile = dataService.getFullUserProfile(); const { deviceId, accessToken, lastChecked } = appData; if (accessToken && userProfile) { // TODO: implement a refresh token flow - if (!lastChecked || lastChecked < addDays(new Date(), -AUTH_CHECK_INTERVAL_DAYS).getTime()) { + if (!lastChecked || lastChecked < addHours(new Date(), -AUTH_CHECK_INTERVAL_HOURS).getTime()) { const response = await verifyAuthToken({ accessToken, deviceId }); if (!response.success) { logger.error('Authentication error', response.error); @@ -266,8 +267,10 @@ const handleCheckAuthEvent: MainIpcHandler<'checkAuth'> = async (): Promise< }); return; } + logger.info('Authentication check successful'); dataService.setAppData({ ...appData, + userProfile: (response as AuthResponseSuccess).userProfile, lastChecked: Date.now(), }); } diff --git a/apps/jetstream-desktop/src/services/protocol.service.ts b/apps/jetstream-desktop/src/services/protocol.service.ts index 1f1b8282c..1bec5f320 100644 --- a/apps/jetstream-desktop/src/services/protocol.service.ts +++ b/apps/jetstream-desktop/src/services/protocol.service.ts @@ -79,6 +79,7 @@ export function registerWebRequestHandlers() { requestHeaders[HTTP.HEADERS.X_SOURCE] = HTTP_SOURCE_DESKTOP; requestHeaders[HTTP.HEADERS.AUTHORIZATION] = `Bearer ${accessToken}`; requestHeaders[HTTP.HEADERS.X_EXT_DEVICE_ID] = deviceId; + requestHeaders[HTTP.HEADERS.X_APP_VERSION] = app.getVersion(); } } callback({ requestHeaders }); diff --git a/apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts b/apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts index 744abb4ed..836e85696 100644 --- a/apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/external-auth-logged-in.spec.ts @@ -28,18 +28,22 @@ test.describe('Desktop / Web-Extension Authentication', () => { test('Desktop Authentication - API', async ({ page, teamCreationUtils1User, apiRequestUtils }) => { const deviceId = uuid(); - const response = await apiRequestUtils.request.post(`/desktop-app/auth/session?deviceId=${deviceId}`); + const response = await apiRequestUtils.request.post(`/desktop-app/auth/session`, { + headers: { + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); expect(response.status()).toBe(200); const data = await response.json().then(({ data }) => data); expect(typeof data.accessToken).toBe('string'); let verifyResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { headers: { + Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${data.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, - data: JSON.stringify({ deviceId, accessToken: data.accessToken }), }); expect(verifyResponse.status()).toBe(200); const verifyData = await verifyResponse.json().then(({ data }) => data); @@ -48,82 +52,99 @@ test.describe('Desktop / Web-Extension Authentication', () => { verifyResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { headers: { + Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${data.accessToken}`, - [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: 'invalid-device-id', }, - data: JSON.stringify({ deviceId: 'invalid-device-id', accessToken: data.accessToken }), }); + verifyResponse.url(); expect(verifyResponse.status()).toBe(401); - const invalidVerifyData1 = await verifyResponse.json().then(({ data }) => data); + const invalidVerifyData1 = await verifyResponse.json(); expect(invalidVerifyData1.success).toBe(false); - expect(invalidVerifyData1.error).toBe('Invalid session'); + expect(invalidVerifyData1.message).toBe('Unauthorized'); verifyResponse = await apiRequestUtils.request.post(`/desktop-app/auth/verify`, { headers: { + Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: `Bearer ${data.accessToken}`, + Authorization: `Bearer invalid-access-token`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, - data: JSON.stringify({ deviceId, accessToken: 'invalid-access-token' }), }); expect(verifyResponse.status()).toBe(401); - const invalidVerifyData2 = await verifyResponse.json().then(({ data }) => data); + const invalidVerifyData2 = await verifyResponse.json(); expect(invalidVerifyData2.success).toBe(false); - expect(invalidVerifyData2.error).toBe('Invalid session'); + expect(invalidVerifyData2.message).toBe('Unauthorized'); }); // TODO: we don't have a way to test this currently since the extension is not installed test('Web Extension Authentication - Extension not installed', async ({ page, teamCreationUtils1User }) => { - await page.goto(`/web-extension/init/`); + await page.goto(`/web-extension/auth/`); await expect(page.getByText('Authentication in progress...')).toBeVisible(); }); test('Web Extension Authentication - API', async ({ page, teamCreationUtils1User, apiRequestUtils }) => { const deviceId = uuid(); - const response = await apiRequestUtils.request.post(`/web-extension/session?deviceId=${deviceId}`); + const response = await apiRequestUtils.request.post(`/web-extension/auth/session`, { + headers: { + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + }, + }); expect(response.status()).toBe(200); const data = await response.json().then(({ data }) => data); expect(typeof data.accessToken).toBe('string'); - let verifyResponse = await apiRequestUtils.request.post(`/web-extension/verify`, { + let verifyResponse = await apiRequestUtils.request.post(`/web-extension/auth/verify`, { headers: { + Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${data.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, - data: JSON.stringify({ deviceId, accessToken: data.accessToken }), }); expect(verifyResponse.status()).toBe(200); const verifyData = await verifyResponse.json().then(({ data }) => data); expect(verifyData.success).toBe(true); expect(verifyData.userProfile).toBeDefined(); - verifyResponse = await apiRequestUtils.request.post(`/web-extension/verify`, { + verifyResponse = await apiRequestUtils.request.post(`/web-extension/auth/verify`, { headers: { + Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${data.accessToken}`, - [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: 'invalid-device-id', }, - data: JSON.stringify({ deviceId: 'invalid-device-id', accessToken: data.accessToken }), }); expect(verifyResponse.status()).toBe(401); - const invalidVerifyData1 = await verifyResponse.json().then(({ data }) => data); + const invalidVerifyData1 = await verifyResponse.json(); expect(invalidVerifyData1.success).toBe(false); - expect(invalidVerifyData1.error).toBe('Invalid session'); + expect(invalidVerifyData1.message).toBe('Unauthorized'); - verifyResponse = await apiRequestUtils.request.post(`/web-extension/verify`, { + verifyResponse = await apiRequestUtils.request.post(`/web-extension/auth/verify`, { headers: { + Accept: 'application/json', 'Content-Type': 'application/json', - Authorization: `Bearer ${data.accessToken}`, + Authorization: `Bearer 'invalid-access-token'`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, - data: JSON.stringify({ deviceId, accessToken: 'invalid-access-token' }), }); expect(verifyResponse.status()).toBe(401); - const invalidVerifyData2 = await verifyResponse.json().then(({ data }) => data); + const invalidVerifyData2 = await verifyResponse.json(); expect(invalidVerifyData2.success).toBe(false); - expect(invalidVerifyData2.error).toBe('Invalid session'); + expect(invalidVerifyData2.message).toBe('Unauthorized'); + + verifyResponse = await apiRequestUtils.request.post(`/web-extension/auth/verify`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer 'invalid-access-token'`, + }, + }); + expect(verifyResponse.status()).toBe(401); + const invalidVerifyData3 = await verifyResponse.json(); + expect(invalidVerifyData3.success).toBe(false); + expect(invalidVerifyData3.message).toBe('Unauthorized'); }); }); @@ -138,7 +159,7 @@ test.describe('Desktop / Web-Extension Authentication - Not Logged In', () => { }); test('Web Extension - Extension not installed', async ({ page }) => { - await page.goto(`/web-extension/init/`); + await page.goto(`/web-extension/auth/`); expect(page.url()).toContain('/auth/login/'); }); }); @@ -159,7 +180,7 @@ test.describe('Desktop / Web-Extension Authentication - No Access', () => { // TODO: we don't have a way to test this currently since the extension is not installed test('Web Extension - Extension not installed', async ({ page }) => { - await page.goto(`/web-extension/init/`); + await page.goto(`/web-extension/auth/`); await expect(page.getByText('Authentication in progress...')).toBeVisible(); }); }); diff --git a/apps/jetstream-web-extension/src/controllers/jetstream-data-sync.web-ext.controller.ts b/apps/jetstream-web-extension/src/controllers/jetstream-data-sync.web-ext.controller.ts index 21cc24442..3a246b43e 100644 --- a/apps/jetstream-web-extension/src/controllers/jetstream-data-sync.web-ext.controller.ts +++ b/apps/jetstream-web-extension/src/controllers/jetstream-data-sync.web-ext.controller.ts @@ -44,6 +44,7 @@ const pull = createRoute(routeDefinition.pull.validators, async ({ query }, req) Accept: 'application/json', Authorization: `Bearer ${authTokens?.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: browser.runtime.getManifest().version, }, }); } catch (ex) { @@ -62,6 +63,7 @@ const push = createRoute(routeDefinition.push.validators, async ({ query, body } 'Content-Type': 'application/json', Authorization: `Bearer ${authTokens?.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: browser.runtime.getManifest().version, }, body: JSON.stringify(body), }); diff --git a/apps/jetstream-web-extension/src/controllers/user.web-ext.controller.ts b/apps/jetstream-web-extension/src/controllers/user.web-ext.controller.ts index aeb8d1777..9097cc5ff 100644 --- a/apps/jetstream-web-extension/src/controllers/user.web-ext.controller.ts +++ b/apps/jetstream-web-extension/src/controllers/user.web-ext.controller.ts @@ -49,6 +49,7 @@ const sendUserFeedbackEmail = createRoute(routeDefinition.sendUserFeedbackEmail. 'Content-Type': contentType, Authorization: `Bearer ${authTokens?.accessToken}`, [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: browser.runtime.getManifest().version, }, body, }); diff --git a/apps/jetstream-web-extension/src/core/GlobalExtensionLoggedOut.tsx b/apps/jetstream-web-extension/src/core/GlobalExtensionLoggedOut.tsx index 30afb6707..1a650a9ea 100644 --- a/apps/jetstream-web-extension/src/core/GlobalExtensionLoggedOut.tsx +++ b/apps/jetstream-web-extension/src/core/GlobalExtensionLoggedOut.tsx @@ -6,7 +6,7 @@ export const GlobalExtensionLoggedOut = () => {

This page is only accessible when you are logged in to the browser extension. Login to continue.

import { enableLogger, logger } from '@jetstream/shared/client-logger'; +import { HTTP } from '@jetstream/shared/constants'; import { addMinutes } from 'date-fns/addMinutes'; import { fromUnixTime } from 'date-fns/fromUnixTime'; import { isAfter } from 'date-fns/isAfter'; @@ -409,11 +410,18 @@ async function handleLogout(sender: browser.Runtime.MessageSender): Promise + const results: { success: true } | { success: false; error: string } = await fetch( + `${environment.serverUrl}/web-extension/auth/logout`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${authTokens.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: browser.runtime.getManifest().version, + }, + }, + ).then((res) => res .json() .then(({ data }) => data) @@ -458,11 +466,18 @@ async function handleVerifyAuth(sender: browser.Runtime.MessageSender): Promise< return { hasTokens: true, loggedIn: true }; } - const results: { success: true } | { success: false; error: string } = await fetch(`${environment.serverUrl}/web-extension/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ accessToken: authTokens.accessToken, deviceId: extIdentifier.id }), - }).then((res) => + const results: { success: true } | { success: false; error: string } = await fetch( + `${environment.serverUrl}/web-extension/auth/verify`, + { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${authTokens.accessToken}`, + [HTTP.HEADERS.X_EXT_DEVICE_ID]: extIdentifier.id, + [HTTP.HEADERS.X_APP_VERSION]: browser.runtime.getManifest().version, + }, + }, + ).then((res) => res .json() .then(({ data }) => data) diff --git a/apps/jetstream-web-extension/src/pages/popup/Popup.tsx b/apps/jetstream-web-extension/src/pages/popup/Popup.tsx index 2d290672c..0b7309390 100644 --- a/apps/jetstream-web-extension/src/pages/popup/Popup.tsx +++ b/apps/jetstream-web-extension/src/pages/popup/Popup.tsx @@ -86,7 +86,7 @@ export function Component() { <>

To get started with Jetstream, sign in to your account.

{ // Provide tokens to the extension window.location.href = `jetstream://auth?deviceId=${deviceId}&token=${token}&accessToken=${tokens.accessToken}`; - sessionStorage.setItem(STORAGE_KEY, 'true'); dispatch({ type: 'SUCCESS' }); + sessionStorage.setItem(STORAGE_KEY, 'true'); }) .catch((err) => { if (err instanceof Error && Object.values(ERROR_MESSAGES).includes(err.message)) { diff --git a/apps/landing/hooks/web-extension.hooks.ts b/apps/landing/hooks/web-extension.hooks.ts index 5baab3888..ba7507b9a 100644 --- a/apps/landing/hooks/web-extension.hooks.ts +++ b/apps/landing/hooks/web-extension.hooks.ts @@ -1,3 +1,4 @@ +import { HTTP } from '@jetstream/shared/constants'; import type { Maybe } from '@jetstream/types'; import { useEffect, useReducer, useRef } from 'react'; import { ENVIRONMENT } from '../utils/environment'; @@ -40,12 +41,13 @@ const EVENT_MAP = { } as const; async function fetchTokens(deviceId: string) { - const response = await fetch(`${ENVIRONMENT.SERVER_URL}/web-extension/session?deviceId=${deviceId}`, { + const response = await fetch(`${ENVIRONMENT.SERVER_URL}/web-extension/auth/session`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json', + [HTTP.HEADERS.X_EXT_DEVICE_ID]: deviceId, }, }); if (!response.ok) { diff --git a/apps/landing/pages/web-extension/init/index.tsx b/apps/landing/pages/web-extension/auth/index.tsx similarity index 100% rename from apps/landing/pages/web-extension/init/index.tsx rename to apps/landing/pages/web-extension/auth/index.tsx diff --git a/libs/api-config/src/lib/api-logger.ts b/libs/api-config/src/lib/api-logger.ts index 923e87219..cb559518c 100644 --- a/libs/api-config/src/lib/api-logger.ts +++ b/libs/api-config/src/lib/api-logger.ts @@ -1,3 +1,4 @@ +import { HTTP } from '@jetstream/shared/constants'; import type express from 'express'; import pino from 'pino'; import pinoHttp from 'pino-http'; @@ -49,9 +50,11 @@ export const httpLogger = pinoHttp({ referer: req.raw.headers.referer, 'cf-ray': req.raw.headers['cf-ray'], 'rndr-id': req.raw.headers['rndr-id'], - 'x-sfdc-id': req.raw.headers['x-sfdc-id'], - 'x-client-request-id': req.raw.headers['x-client-request-id'], - 'x-retry': req.raw.headers['x-retry'], + 'x-sfdc-id': req.raw.headers[HTTP.HEADERS.X_SFDC_ID.toLowerCase()], + 'x-client-request-id': req.raw.headers[HTTP.HEADERS.X_CLIENT_REQUEST_ID.toLowerCase()], + 'x-retry': req.raw.headers[HTTP.HEADERS.X_RETRY.toLowerCase()], + 'x-ext-id': req.raw.headers[HTTP.HEADERS.X_EXT_DEVICE_ID.toLowerCase()], + 'x-app-version': req.raw.headers[HTTP.HEADERS.X_APP_VERSION.toLowerCase()], ip: req.raw.headers['cf-connecting-ip'] || req.raw.headers['x-forwarded-for'] || req.raw.socket.remoteAddress, country: req.headers['cf-ipcountry'], }, diff --git a/libs/api-types/src/lib/api-route.types.ts b/libs/api-types/src/lib/api-route.types.ts index c64ae836d..5b359a2ac 100644 --- a/libs/api-types/src/lib/api-route.types.ts +++ b/libs/api-types/src/lib/api-route.types.ts @@ -37,5 +37,9 @@ export type Response = ExpressResponse< */ cookies?: ResponseLocalsCookies; ipAddress: string; + /** + * Used for desktop and web-extension requests to track the device ID + */ + deviceId?: string; } > & { log: pino.Logger }; diff --git a/libs/auth/server/src/lib/auth-logging.db.service.ts b/libs/auth/server/src/lib/auth-logging.db.service.ts index 9e914eb7d..5fc5891d0 100644 --- a/libs/auth/server/src/lib/auth-logging.db.service.ts +++ b/libs/auth/server/src/lib/auth-logging.db.service.ts @@ -24,7 +24,9 @@ export type Action = | 'REVOKE_SESSION' | 'DELETE_ACCOUNT' | 'DESKTOP_LOGIN_TOKEN_ISSUED' - | 'WEB_EXTENSION_LOGIN_TOKEN_ISSUED'; + | 'DESKTOP_LOGIN_TOKEN_REUSED' + | 'WEB_EXTENSION_LOGIN_TOKEN_ISSUED' + | 'WEB_EXTENSION_LOGIN_TOKEN_REUSED'; export const actionDisplayName: Record = { LOGIN: 'Login Attempt', @@ -46,7 +48,9 @@ export const actionDisplayName: Record = { REVOKE_SESSION: 'Revoke Session', DELETE_ACCOUNT: 'Delete Account', DESKTOP_LOGIN_TOKEN_ISSUED: 'Desktop Login Token Issued', + DESKTOP_LOGIN_TOKEN_REUSED: 'Desktop Login Token Reused', WEB_EXTENSION_LOGIN_TOKEN_ISSUED: 'Web Extension Login Token Issued', + WEB_EXTENSION_LOGIN_TOKEN_REUSED: 'Web Extension Login Token Reused', }; export const methodDisplayName: Record = { diff --git a/libs/auth/types/src/lib/auth-types.globals.ts b/libs/auth/types/src/lib/auth-types.globals.ts index fb6a4dc28..fd7f2d5f1 100644 --- a/libs/auth/types/src/lib/auth-types.globals.ts +++ b/libs/auth/types/src/lib/auth-types.globals.ts @@ -1,3 +1,4 @@ +import { Maybe } from '@jetstream/types'; import 'express'; import 'express-session'; import { SessionData as JetstreamSessionData, UserProfileSession } from './auth-types'; @@ -10,7 +11,7 @@ declare module 'express' { */ externalAuth?: { user: UserProfileSession; - deviceId?: string; + deviceId?: Maybe; }; } } From f1e355c5559d5e7238fd190bdff837de59b9b5b6 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Thu, 1 Jan 2026 12:23:26 -0600 Subject: [PATCH 4/4] fix: incorrect openapi route method --- apps/api/src/app/routes/openapi.routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app/routes/openapi.routes.ts b/apps/api/src/app/routes/openapi.routes.ts index 6b5a21d1f..d51945480 100644 --- a/apps/api/src/app/routes/openapi.routes.ts +++ b/apps/api/src/app/routes/openapi.routes.ts @@ -582,7 +582,7 @@ export function getOpenApiSpec() { post: { ...getRequest({ ...desktopController.dataSyncPush.validators, tags: ['desktop'] }) }, }, '/desktop-app/v1/notifications': { - post: { ...getRequest({ ...desktopController.notifications.validators, tags: ['desktop'] }) }, + get: { ...getRequest({ ...desktopController.notifications.validators, tags: ['desktop'] }) }, }, // Web Extension Controller Routes (prefix: /web-extension)