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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 89 additions & 50 deletions apps/api/src/app/controllers/desktop-app.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -26,31 +34,40 @@ 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,
},
},
initSession: {
controllerFn: () => initSession,
responseType: z.object({ accessToken: z.string() }),
validators: {
query: z.object({
deviceId: z.uuid(),
}),
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(),
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,
},
},
Expand Down Expand Up @@ -98,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;
}
Expand All @@ -111,61 +128,83 @@ 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),
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 });
createUserActivityFromReq(req, res, {
action: 'DESKTOP_LOGIN_TOKEN_REUSED',
success: true,
});
} else {
// return existing token since it is still valid
accessToken = existingRecord.token;
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 ({ 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);
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 });
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);
}
});
Expand Down Expand Up @@ -205,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

Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/app/controllers/desktop-assets.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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' });
}
});
Expand Down Expand Up @@ -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' });
}
});
Loading