Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to disable frontdoor login #723

Merged
merged 4 commits into from
Feb 10, 2024
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
4 changes: 2 additions & 2 deletions apps/api/src/app/controllers/sf-query.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { NextFunction, Request, Response } from 'express';
import { body, query as queryString } from 'express-validator';
import * as jsforce from 'jsforce';
import * as queryService from '../services/query';
import { sendJson } from '../utils/response.handlers';
import { UserFacingError } from '../utils/error-handler';
import { body, query as queryString } from 'express-validator';
import { sendJson } from '../utils/response.handlers';

export const routeValidators = {
query: [body('query').isString()],
Expand Down
75 changes: 63 additions & 12 deletions apps/api/src/app/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { logger, mailgun } from '@jetstream/api-config';
import { UserProfileServer } from '@jetstream/types';
import { ENV, logger, mailgun } from '@jetstream/api-config';
import { UserProfileAuth0Ui, UserProfileServer, UserProfileUi, UserProfileUiWithIdentities } from '@jetstream/types';
import { AxiosError } from 'axios';
import * as express from 'express';
import { body, query as queryString } from 'express-validator';
import { deleteUserAndOrgs } from '../db/transactions.db';
import * as userDbService from '../db/user.db';
import * as auth0Service from '../services/auth0';
import { UserFacingError } from '../utils/error-handler';
import { sendJson } from '../utils/response.handlers';

export const routeValidators = {
updateProfile: [body('name').isString().isLength({ min: 1, max: 255 })],
updateProfile: [body('name').isString().isLength({ min: 1, max: 255 }), body('preferences').isObject().optional()],
unlinkIdentity: [queryString('provider').isString().isLength({ min: 1 }), queryString('userId').isString().isLength({ min: 1 })],
resendVerificationEmail: [queryString('provider').isString().isLength({ min: 1 }), queryString('userId').isString().isLength({ min: 1 })],
deleteAccount: [body('reason').isString().optional()],
Expand Down Expand Up @@ -58,16 +59,62 @@ export async function emailSupport(req: express.Request, res: express.Response)
}

export async function getUserProfile(req: express.Request, res: express.Response) {
const user = req.user as UserProfileServer;
sendJson(res, user._json);
const auth0User = req.user as UserProfileServer;

// use fallback locally and on CI
if (ENV.EXAMPLE_USER_OVERRIDE && ENV.EXAMPLE_USER_PROFILE && req.hostname === 'localhost') {
sendJson(res, ENV.EXAMPLE_USER_PROFILE);
return;
}

const user = await userDbService.findByUserId(auth0User.id);
if (!user) {
throw new UserFacingError('User not found');
}
const userProfileUi: UserProfileUi = {
...(auth0User._json as any),
id: user.id,
userId: user.userId,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
preferences: {
skipFrontdoorLogin: user.preferences?.skipFrontdoorLogin,
},
};
sendJson(res, userProfileUi);
}

async function getFullUserProfileFn(sessionUser: UserProfileServer, auth0User?: UserProfileAuth0Ui) {
auth0User = auth0User || (await auth0Service.getUser(sessionUser));
const jetstreamUser = await userDbService.findByUserId(sessionUser.id);
if (!jetstreamUser) {
throw new UserFacingError('User not found');
}
const response: UserProfileUiWithIdentities = {
id: jetstreamUser.id,
userId: sessionUser.id,
name: jetstreamUser.name || '',
email: jetstreamUser.email,
emailVerified: auth0User.email_verified,
username: auth0User.username || '',
nickname: auth0User.nickname,
picture: auth0User.picture,
preferences: {
skipFrontdoorLogin: jetstreamUser.preferences?.skipFrontdoorLogin ?? false,
},
identities: auth0User.identities,
createdAt: jetstreamUser.createdAt.toISOString(),
updatedAt: jetstreamUser.updatedAt.toISOString(),
};
return response;
}

/** Get profile from Auth0 */
export async function getFullUserProfile(req: express.Request, res: express.Response) {
const user = req.user as UserProfileServer;
try {
const auth0User = await auth0Service.getUser(user);
sendJson(res, auth0User);
const response = await getFullUserProfileFn(user);
sendJson(res, response);
} catch (ex) {
if (ex.isAxiosError) {
const error: AxiosError = ex;
Expand All @@ -86,11 +133,14 @@ export async function getFullUserProfile(req: express.Request, res: express.Resp

export async function updateProfile(req: express.Request, res: express.Response) {
const user = req.user as UserProfileServer;
const userProfile = { name: req.body.name };
const userProfile = req.body as UserProfileUiWithIdentities;

try {
// check for name change, if so call auth0 to update
const auth0User = await auth0Service.updateUser(user, userProfile);
sendJson(res, auth0User);
// update name and preferences locally
const response = await getFullUserProfileFn(user, auth0User);
sendJson(res, response);
} catch (ex) {
if (ex.isAxiosError) {
const error: AxiosError = ex;
Expand All @@ -114,7 +164,8 @@ export async function unlinkIdentity(req: express.Request, res: express.Response
const userId = req.query.userId as string;

const auth0User = await auth0Service.unlinkIdentity(user, { provider, userId });
sendJson(res, auth0User);
const response = await getFullUserProfileFn(user, auth0User);
sendJson(res, response);
} catch (ex) {
if (ex.isAxiosError) {
const error: AxiosError = ex;
Expand All @@ -136,8 +187,8 @@ export async function resendVerificationEmail(req: express.Request, res: express
const provider = req.query.provider as string;
const userId = req.query.userId as string;
try {
const auth0User = await auth0Service.resendVerificationEmail(user, { provider, userId });
sendJson(res, auth0User);
await auth0Service.resendVerificationEmail(user, { provider, userId });
sendJson(res);
} catch (ex) {
if (ex.isAxiosError) {
const error: AxiosError = ex;
Expand Down
68 changes: 63 additions & 5 deletions apps/api/src/app/db/user.db.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,70 @@
import { ENV, logger, prisma } from '@jetstream/api-config';
import { UserProfileServer } from '@jetstream/types';
import { User } from '@prisma/client';
import { Prisma, User } from '@prisma/client';

const userSelect: Prisma.UserSelect = {
appMetadata: true,
createdAt: true,
email: true,
id: true,
name: true,
nickname: true,
picture: true,
preferences: {
select: {
skipFrontdoorLogin: true,
},
},
updatedAt: true,
userId: true,
};

/**
* Find by Auth0 userId, not Jetstream Id
*/
async function findByUserId(userId: string) {
return await prisma.user.findUnique({
export async function findByUserId(userId: string) {
let user = await prisma.user.findUnique({
where: { userId: userId },
select: userSelect,
});
// ensure user preference exists if not already created
if (user && !user.preferences) {
await prisma.userPreference.create({
data: {
userId: user.userId,
skipFrontdoorLogin: false,
},
});
user = await prisma.user.findUnique({
where: { userId: userId },
select: userSelect,
});
}
return user;
}

export async function updateUser(user: UserProfileServer, data: { name: string }): Promise<User> {
export async function updateUser(
user: UserProfileServer,
data: { name: string; preferences: { skipFrontdoorLogin: boolean } }
): Promise<User> {
try {
const existingUser = await prisma.user.findUnique({
where: { userId: user.id },
select: { id: true, preferences: { select: { skipFrontdoorLogin: true } } },
});
const skipFrontdoorLogin = data.preferences.skipFrontdoorLogin ?? (existingUser?.preferences?.skipFrontdoorLogin || false);
const updatedUser = await prisma.user.update({
where: { userId: user.id },
data: { name: data.name },
data: {
name: data.name,
preferences: {
upsert: {
create: { skipFrontdoorLogin },
update: { skipFrontdoorLogin },
},
},
},
select: userSelect,
});
return updatedUser;
} catch (ex) {
Expand All @@ -36,7 +85,14 @@ export async function createOrUpdateUser(user: UserProfileServer): Promise<{ cre
where: { userId: user.id },
data: {
appMetadata: JSON.stringify(user._json[ENV.AUTH_AUDIENCE!]),
preferences: {
connectOrCreate: {
create: { skipFrontdoorLogin: false },
where: { userId: user.id },
},
},
},
select: userSelect,
});
logger.debug('[DB][USER][UPDATED] %s', user.id, { userId: user.id, id: existingUser.id });
return { created: false, user: updatedUser };
Expand All @@ -49,7 +105,9 @@ export async function createOrUpdateUser(user: UserProfileServer): Promise<{ cre
nickname: user._json.nickname,
picture: user._json.picture,
appMetadata: JSON.stringify(user._json[ENV.AUTH_AUDIENCE!]),
preferences: { create: { skipFrontdoorLogin: false } },
},
select: userSelect,
});
logger.debug('[DB][USER][CREATED] %s', user.id, { userId: user.id, id: createdUser.id });
return { created: true, user: createdUser };
Expand Down
11 changes: 7 additions & 4 deletions apps/api/src/app/services/auth0.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ENV, logger } from '@jetstream/api-config';
import { UserProfileAuth0Identity, UserProfileAuth0Ui, UserProfileServer } from '@jetstream/types';
import { UserProfileAuth0Identity, UserProfileAuth0Ui, UserProfileServer, UserProfileUiWithIdentities } from '@jetstream/types';
import axios, { AxiosError } from 'axios';
import { addHours, addSeconds, formatISO, isBefore } from 'date-fns';
import * as userDb from '../db/user.db';
Expand Down Expand Up @@ -76,10 +76,13 @@ export async function updateUserLastActivity(user: UserProfileServer, lastActivi
).data;
}

export async function updateUser(user: UserProfileServer, userProfile: { name: string }): Promise<UserProfileAuth0Ui> {
export async function updateUser(user: UserProfileServer, userProfile: UserProfileUiWithIdentities): Promise<UserProfileAuth0Ui> {
await initAuthorizationToken(user);
// update on Auth0
await axiosAuth0.patch<UserProfileAuth0Ui>(`/api/v2/users/${user.id}`, userProfile);

if (user.displayName !== userProfile.name) {
// update on Auth0 if name changed (not allowed for OAuth connections)
await axiosAuth0.patch<UserProfileAuth0Ui>(`/api/v2/users/${user.id}`, { name: userProfile.name });
}
// update locally
await userDb.updateUser(user, userProfile);
// re-fetch user from Auth0
Expand Down
15 changes: 5 additions & 10 deletions apps/docs/docs/getting-started/_org-troubleshooting-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,8 @@ export const OAuthSettingsSolution = ({ children }) => (

If you have issues adding your org, here are some likely causes and solutions.

| Problem | Possible Causes | Solution |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| You are unable to login, your username and password is not accepted from Salesforce | Your org may have a login restriction to only allow access by logging in using the custom domain. | This setting can be found in Salesforce under `Setup` → `My Domain` → `Policies` → `Prevent login from https://login.salesforce.com`. <br /> <br /> If this is set to true, then you will want to use the **Custom Login URL** option and provide custom domain shown on the **Current My Domain URL** on the setup page. |
| You receive an error message after successfully logging in | <OAuthSettingsList /> | <OAuthSettingsSolution /> |

:::important

Jetstream uses a wide range of IP addresses, so you may need to relax IP address restrictions for the Jetstream Connected App.

:::
| Problem | Possible Causes | Solution |
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| You are unable to login, your username and password is not accepted from Salesforce | Your org may have a login restriction to only allow access by logging in using the custom domain. | This setting can be found in Salesforce under `Setup` → `My Domain` → `Policies` → `Prevent login from https://login.salesforce.com`. <br /> <br /> If this is set to true, then you will want to use the **Custom Login URL** option and provide custom domain shown on the **Current My Domain URL** on the setup page. |
| You receive an error message after successfully logging in | <OAuthSettingsList /> | <OAuthSettingsSolution /> |
| When you click a Salesforce link, you are required to "Choose a Verification Method" to continue to Salesforce. | You may have very strict session settings in Salesforce, such as "High Assurance". | By default, Jetstream uses [Frontdoor](https://help.salesforce.com/s/articleView?id=sf.security_frontdoorjsp.htm&type=5) to login using your existing Jetstream session. <br /> <br /> This can be disabled by navigating to your settings and enabling the option to **Don't Auto-Login on Link Clicks**. <br /> <br /> <img src={require('./settings-dont-auto-login.png').default} alt="Disable Auto-Login" /> |
2 changes: 1 addition & 1 deletion apps/docs/docs/getting-started/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ You will be asked to choose an org type which is used to determine the which Sal

### Jetstream IP Addresses

If you need to whitelist IP addresses for your org, Jetream will use one of the following three ip addresses:
If you need to whitelist IP addresses for your org, Jetstream will use one of the following three ip addresses:

- `3.134.238.10`
- `3.129.111.220`
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions apps/jetstream/src/app/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ export const salesforceOrgsOmitSelectedState = selector({
},
});

export const selectSkipFrontdoorAuth = selector({
key: 'selectSkipFrontdoorAuth',
get: ({ get }) => {
const userProfile = get(userProfileState);
return userProfile?.preferences?.skipFrontdoorLogin || false;
},
});

export const salesforceOrgsById = selector({
key: 'salesforceOrgsById',
get: ({ get }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import escapeRegExp from 'lodash/escapeRegExp';
import type { editor } from 'monaco-editor';
import { Fragment, FunctionComponent, MouseEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { STORAGE_KEYS, applicationCookieState, selectedOrgState } from '../../app-state';
import { STORAGE_KEYS, applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '../../app-state';
import { useAmplitude } from '../core/analytics';
import AnonymousApexFilter from './AnonymousApexFilter';
import AnonymousApexHistory from './AnonymousApexHistory';
Expand Down Expand Up @@ -55,7 +55,8 @@ export const AnonymousApex: FunctionComponent<AnonymousApexProps> = () => {
const logRef = useRef<editor.IStandaloneCodeEditor>();
const { trackEvent } = useAmplitude();
const rollbar = useRollbar();
const [{ serverUrl }] = useRecoilState(applicationCookieState);
const { serverUrl } = useRecoilValue(applicationCookieState);
const skipFrontDoorAuth = useRecoilValue(selectSkipFrontdoorAuth);
const selectedOrg = useRecoilValue<SalesforceOrgUi>(selectedOrgState);
const [apex, setApex] = useState(() => localStorage.getItem(STORAGE_KEYS.ANONYMOUS_APEX_STORAGE_KEY) || '');
const [results, setResults] = useState('');
Expand Down Expand Up @@ -273,6 +274,7 @@ export const AnonymousApex: FunctionComponent<AnonymousApexProps> = () => {
className="slds-m-right_x-small"
serverUrl={serverUrl}
org={selectedOrg}
skipFrontDoorAuth={skipFrontDoorAuth}
returnUrl="/_ui/common/apex/debug/ApexCSIPage"
omitIcon
title="Open developer console"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ import {
import classNames from 'classnames';
import { FunctionComponent, useState } from 'react';
import { Link } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { applicationCookieState, selectedOrgState } from '../../app-state';
import { useRecoilValue } from 'recoil';
import { applicationCookieState, selectSkipFrontdoorAuth, selectedOrgState } from '../../app-state';
import { RequireMetadataApiBanner } from '../core/RequireMetadataApiBanner';
import { useAmplitude } from '../core/analytics';
import * as fromJetstreamEvents from '../core/jetstream-events';
Expand Down Expand Up @@ -60,7 +60,9 @@ export const AutomationControlEditor: FunctionComponent<AutomationControlEditorP
const { trackEvent } = useAmplitude();

const selectedOrg = useRecoilValue<SalesforceOrgUi>(selectedOrgState);
const [{ serverUrl, defaultApiVersion, google_apiKey, google_appId, google_clientId }] = useRecoilState(applicationCookieState);
const { serverUrl, defaultApiVersion, google_apiKey, google_appId, google_clientId } = useRecoilValue(applicationCookieState);
const skipFrontdoorLogin = useRecoilValue(selectSkipFrontdoorAuth);

const selectedSObjects = useRecoilValue(fromAutomationCtlState.selectedSObjectsState);
const selectedAutomationTypes = useRecoilValue(fromAutomationCtlState.selectedAutomationTypes);

Expand Down Expand Up @@ -368,6 +370,7 @@ export const AutomationControlEditor: FunctionComponent<AutomationControlEditorP
>
<AutomationControlEditorTable
serverUrl={serverUrl}
skipFrontdoorLogin={skipFrontdoorLogin}
selectedOrg={selectedOrg}
rows={visibleRows}
quickFilterText={quickFilterText}
Expand Down
Loading
Loading