Line data Source code
1 : import * as auth from '@ankoh/dashql-jsonschema/auth.js';
2 : import * as connection from '@ankoh/dashql-jsonschema/connection.js';
3 :
4 : import type { OAuthRedirectData, OAuthState } from '../../oauth/oauth_types.js';
5 : import { isDebugBuild } from '../../globals.js';
6 : import { PlatformEventListener } from '../../platform/events/event_listener.js';
7 : import { Logger, stringifyError } from '../../platform/logger/logger.js';
8 : import { PlatformType } from '../../platform/platform_type.js';
9 : import { BASE64URL_CODEC } from '../../utils/base64.js';
10 : import { generatePKCEChallenge } from '../../utils/pkce.js';
11 : import { SalesforceApiClientInterface } from './salesforce_api_client.js';
12 :
13 1 : const LOG_CTX = 'salesforce_authentication';
14 1 : const DEFAULT_EXPIRATION_TIME_MS = 2 * 60 * 60 * 1000;
15 1 : const OAUTH_POPUP_NAME = 'DashQL OAuth';
16 1 : const OAUTH_POPUP_SETTINGS = 'toolbar=no, menubar=no, width=600, height=700, top=100, left=100';
17 :
18 : export type SalesforceAuthenticationProgress =
19 : | { stage: 'GENERATING_PKCE_CHALLENGE' }
20 : | { stage: 'GENERATED_PKCE_CHALLENGE'; pkceChallenge: auth.OAuthPKCEChallenge }
21 : | { stage: 'OAUTH_NATIVE_LINK_OPENED' }
22 : | { stage: 'OAUTH_WEB_WINDOW_OPENED' }
23 : | { stage: 'OAUTH_WEB_WINDOW_CLOSED' }
24 : | { stage: 'RECEIVED_CORE_AUTH_CODE'; code: string }
25 : | { stage: 'REQUESTING_CORE_AUTH_TOKEN' }
26 : | { stage: 'RECEIVED_CORE_AUTH_TOKEN'; coreAccessToken: connection.SalesforceCoreAccessToken }
27 : | { stage: 'RECEIVED_CORE_USER_INFO'; coreUserInfo: connection.SalesforceCoreUserInfo }
28 : | { stage: 'REQUESTING_DATA_CLOUD_ACCESS_TOKEN' }
29 : | { stage: 'RECEIVED_DATA_CLOUD_ACCESS_TOKEN'; dataCloudAccessToken: connection.SalesforceDataCloudAccessToken };
30 :
31 : export interface SalesforceAuthenticationResult {
32 : coreAccessToken: connection.SalesforceCoreAccessToken;
33 : dataCloudAccessToken: connection.SalesforceDataCloudAccessToken;
34 : coreUserInfo?: connection.SalesforceCoreUserInfo;
35 : }
36 :
37 : export interface SalesforceAuthenticationOptions {
38 : logger: Logger;
39 : params: connection.SalesforceConnectionParams;
40 : authConfig: connection.SalesforceOAuthConfig;
41 : platformType: PlatformType;
42 : apiClient: SalesforceApiClientInterface;
43 : appEvents: PlatformEventListener;
44 : forceReLogin: boolean;
45 : abortSignal: AbortSignal;
46 : oauthPopup?: Window | null;
47 : onProgress?: (progress: SalesforceAuthenticationProgress) => void;
48 : }
49 :
50 1 : export function buildSalesforceAuthorizationUrl(
51 1 : params: connection.SalesforceConnectionParams,
52 1 : authConfig: connection.SalesforceOAuthConfig,
53 1 : pkceChallenge: auth.OAuthPKCEChallenge,
54 1 : state: OAuthState,
55 1 : forceReLogin: boolean,
56 1 : ): URL {
57 1 : const url = new URL('/services/oauth2/authorize', params.instanceUrl);
58 1 : url.search = new URLSearchParams({
59 1 : client_id: params.appConsumerKey,
60 1 : redirect_uri: authConfig.oauthRedirect,
61 1 : code_challenge: pkceChallenge.value,
62 1 : code_challenge_method: 'S256',
63 1 : response_type: 'code',
64 1 : state: BASE64URL_CODEC.encode(new TextEncoder().encode(JSON.stringify(state)).buffer),
65 1 : }).toString();
66 1 : if (forceReLogin) {
67 1 : url.searchParams.set('prompt', 'login');
68 : }
69 1 : if (params.login) {
70 1 : url.searchParams.set('login_hint', params.login);
71 : }
72 1 : return url;
73 1 : }
74 :
75 1 : export function validateSalesforceOAuthCallbackState(
76 1 : callback: OAuthRedirectData,
77 1 : expected: OAuthState,
78 1 : now = Date.now(),
79 1 : ): void {
80 1 : const actual = callback.state;
81 1 : const actualProvider = actual?.salesforceProvider;
82 1 : const expectedProvider = expected.salesforceProvider;
83 1 : if (!actual || !actualProvider || !expectedProvider) {
84 0 : throw new Error('OAuth callback state is missing the Salesforce provider');
85 : }
86 1 : if (actual.flowId !== expected.flowId) {
87 1 : throw new Error('OAuth callback flow ID does not match');
88 : }
89 1 : if (actual.flowVariant !== expected.flowVariant) {
90 1 : throw new Error('OAuth callback flow variant does not match');
91 : }
92 1 : if (actualProvider.instanceUrl !== expectedProvider.instanceUrl
93 1 : || actualProvider.appConsumerKey !== expectedProvider.appConsumerKey) {
94 1 : throw new Error('OAuth callback Salesforce provider does not match');
95 : }
96 1 : if (actualProvider.expiresAt !== expectedProvider.expiresAt || actualProvider.expiresAt <= now) {
97 1 : throw new Error('OAuth callback state has expired');
98 : }
99 1 : }
100 :
101 1 : export async function authenticateSalesforce(options: SalesforceAuthenticationOptions): Promise<SalesforceAuthenticationResult> {
102 1 : const {
103 1 : logger,
104 1 : params,
105 1 : authConfig,
106 1 : platformType,
107 1 : apiClient,
108 1 : appEvents,
109 1 : forceReLogin,
110 1 : abortSignal,
111 1 : oauthPopup: reservedOAuthPopup,
112 1 : onProgress = () => {},
113 1 : } = options;
114 1 : let oauthPopup: Window | null = reservedOAuthPopup ?? null;
115 1 : const closeOAuthPopup = () => {
116 1 : if (!oauthPopup) return;
117 1 : if (!oauthPopup.closed) {
118 1 : oauthPopup.close();
119 : }
120 1 : oauthPopup = null;
121 1 : onProgress({ stage: 'OAUTH_WEB_WINDOW_CLOSED' });
122 1 : };
123 :
124 1 : try {
125 1 : abortSignal.throwIfAborted();
126 1 : onProgress({ stage: 'GENERATING_PKCE_CHALLENGE' });
127 1 : const pkceChallenge = await generatePKCEChallenge();
128 1 : abortSignal.throwIfAborted();
129 1 : onProgress({ stage: 'GENERATED_PKCE_CHALLENGE', pkceChallenge });
130 :
131 1 : const flowVariant: OAuthState['flowVariant'] = platformType === PlatformType.WEB
132 1 : ? 'WEB_OPENER_FLOW'
133 1 : : 'NATIVE_LINK_FLOW';
134 1 : const requestedAt = Date.now();
135 : // The redirect page cannot see the flavor of the app, the url of the redirect is fixed by the connected app.
136 : // The flavor provider brands the document with the resolved flavor, pass that one through the state.
137 1 : const flavor = globalThis.document?.documentElement.dataset.flavor;
138 1 : const state: OAuthState = {
139 1 : flowId: crypto.randomUUID(),
140 1 : flowVariant,
141 1 : debugMode: isDebugBuild(),
142 1 : ...(flowVariant === 'WEB_OPENER_FLOW'
143 1 : ? { callbackUrl: new URL('/oauth.html', window.location.origin).toString() }
144 1 : : {}),
145 1 : ...(flavor ? { flavor } : {}),
146 1 : salesforceProvider: {
147 1 : instanceUrl: params.instanceUrl,
148 1 : appConsumerKey: params.appConsumerKey,
149 1 : requestedAt,
150 1 : expiresAt: requestedAt + DEFAULT_EXPIRATION_TIME_MS,
151 1 : },
152 1 : };
153 1 : const authorizationUrl = buildSalesforceAuthorizationUrl(
154 1 : params,
155 1 : authConfig,
156 1 : pkceChallenge,
157 1 : state,
158 1 : forceReLogin,
159 1 : );
160 1 : const authorizationParameters = Object.fromEntries(authorizationUrl.searchParams.entries());
161 1 : authorizationParameters.state = '<redacted>';
162 1 : authorizationParameters.code_challenge = '<redacted>';
163 1 : if (authorizationParameters.login_hint) authorizationParameters.login_hint = '<redacted>';
164 1 : logger.info('Opening Salesforce OAuth authorization URL', {
165 1 : endpoint: `${authorizationUrl.origin}${authorizationUrl.pathname}`,
166 1 : flowVariant,
167 1 : authorizationParameters: JSON.stringify(authorizationParameters),
168 1 : oauthState: JSON.stringify({
169 1 : flowId: '<redacted>',
170 1 : flowVariant: state.flowVariant,
171 1 : debugMode: state.debugMode,
172 1 : callbackUrl: state.callbackUrl ?? null,
173 1 : flavor: state.flavor ?? null,
174 1 : salesforceProvider: {
175 1 : instanceUrl: state.salesforceProvider?.instanceUrl,
176 1 : appConsumerKey: state.salesforceProvider?.appConsumerKey,
177 1 : requestedAt: state.salesforceProvider?.requestedAt,
178 1 : expiresAt: state.salesforceProvider?.expiresAt,
179 1 : },
180 1 : }),
181 1 : }, LOG_CTX);
182 :
183 1 : if (flowVariant === 'NATIVE_LINK_FLOW') {
184 1 : const bridge = globalThis.dashqlElectron;
185 1 : if (!bridge) throw new Error('desktop OAuth bridge is unavailable');
186 1 : await bridge.openExternal(authorizationUrl.toString());
187 1 : onProgress({ stage: 'OAUTH_NATIVE_LINK_OPENED' });
188 1 : } else {
189 1 : const popup = oauthPopup ?? window.open(authorizationUrl, OAUTH_POPUP_NAME, OAUTH_POPUP_SETTINGS);
190 1 : if (!popup) {
191 0 : throw new Error('could not open oauth window');
192 : }
193 1 : oauthPopup = popup;
194 1 : if (reservedOAuthPopup) popup.location.replace(authorizationUrl.toString());
195 1 : popup.focus();
196 1 : onProgress({ stage: 'OAUTH_WEB_WINDOW_OPENED' });
197 1 : }
198 :
199 1 : const callback = await appEvents.waitForOAuthRedirect(
200 1 : abortSignal,
201 1 : candidate => candidate.state?.flowId === state.flowId,
202 1 : ) as OAuthRedirectData;
203 1 : abortSignal.throwIfAborted();
204 1 : closeOAuthPopup();
205 1 : validateSalesforceOAuthCallbackState(callback, state);
206 1 : if (callback.error) {
207 1 : throw new Error(callback.errorDescription || callback.error);
208 : }
209 1 : if (!callback.code) {
210 0 : throw new Error('OAuth callback did not include an authorization code');
211 : }
212 1 : onProgress({ stage: 'RECEIVED_CORE_AUTH_CODE', code: callback.code });
213 :
214 1 : onProgress({ stage: 'REQUESTING_CORE_AUTH_TOKEN' });
215 1 : const coreAccessToken = await apiClient.getCoreAccessToken(
216 1 : authConfig,
217 1 : params,
218 1 : callback.code,
219 1 : pkceChallenge.verifier,
220 1 : abortSignal,
221 1 : );
222 1 : abortSignal.throwIfAborted();
223 1 : onProgress({ stage: 'RECEIVED_CORE_AUTH_TOKEN', coreAccessToken });
224 :
225 1 : let coreUserInfo: connection.SalesforceCoreUserInfo | undefined;
226 1 : try {
227 1 : coreUserInfo = await apiClient.getCoreUserInfo(coreAccessToken, abortSignal);
228 1 : abortSignal.throwIfAborted();
229 1 : onProgress({ stage: 'RECEIVED_CORE_USER_INFO', coreUserInfo });
230 1 : } catch (error: any) {
231 1 : if (error?.name === 'AbortError') {
232 0 : throw error;
233 : }
234 1 : logger.info('Could not resolve core user info; login hint will be skipped', {
235 1 : error: stringifyError(error),
236 1 : }, LOG_CTX);
237 1 : }
238 :
239 1 : onProgress({ stage: 'REQUESTING_DATA_CLOUD_ACCESS_TOKEN' });
240 1 : const dataCloudAccessToken = await apiClient.getDataCloudAccessToken(coreAccessToken, abortSignal);
241 1 : abortSignal.throwIfAborted();
242 1 : onProgress({ stage: 'RECEIVED_DATA_CLOUD_ACCESS_TOKEN', dataCloudAccessToken });
243 1 : return { coreAccessToken, dataCloudAccessToken, coreUserInfo };
244 1 : } finally {
245 1 : closeOAuthPopup();
246 1 : }
247 1 : }
|