LCOV - code coverage report
Current view: top level - dashql-app/src/connections/salesforce - salesforce_authentication.ts Coverage Total Hit
Test: DashQL 915828b Lines: 97.8 % 179 175
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1