LCOV - code coverage report
Current view: top level - dashql-app/src/oauth - oauth_redirect.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 342 342
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import symbols from '@ankoh/dashql-svg-symbols';
       3              : import * as baseStyles from '../ui/foundations/banner_page.module.css';
       4              : import * as styles from './oauth_redirect.module.css';
       5              : 
       6              : import type { OAuthState, AppEventData } from './oauth_types.js';
       7              : 
       8              : import { useSearchParams } from 'react-router-dom';
       9              : 
      10              : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
      11              : import { BASE64URL_CODEC } from '../utils/base64.js';
      12              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../ui/foundations/button.js';
      13              : import { CopyToClipboardButton } from '../ui/foundations/clipboard.js';
      14              : import { DASHQL_VERSION } from '../globals.js';
      15              : import { FlavorLogo, useAppFlavor } from '../config/flavor_provider.js';
      16              : import { LogsOverlay } from '../ui/logs/logs_overlay.js';
      17              : import { Logger, stringifyError } from '../platform/logger/logger.js';
      18              : import { useLogger } from '../platform/logger/logger_provider.js';
      19              : import { Result, RESULT_ERROR, RESULT_OK } from '../utils/result.js';
      20              : import { TextField, TextFieldValidationStatus, VALIDATION_ERROR, VALIDATION_WARNING } from '../ui/foundations/text_field.js';
      21              : import { classNames } from '../utils/classnames.js';
      22              : import { formatHHMMSS, formatTimeDifference } from '../utils/format.js';
      23              : import { APP_EVENT_POST_MESSAGE_KIND, OAUTH_BROADCAST_CHANNEL } from '../platform/events/event.js';
      24              : import { decodeOAuthState, isAllowedOAuthCallbackUrl, validateOAuthRedirectState } from './oauth_redirect_utils.js';
      25              : 
      26            1 : const AUTO_TRIGGER_DELAY = 2000;
      27            1 : const AUTO_TRIGGER_COUNTER_INTERVAL = 200;
      28              : 
      29            1 : const LOG_CTX = "oauth_redirect";
      30              : 
      31              : interface OAuthSucceededProps {
      32              :     params: URLSearchParams;
      33              :     state: OAuthState;
      34              : }
      35              : 
      36            1 : function buildDeepLink(eventBase64: string) {
      37            1 :     return new URL(`dashql://localhost?data=${eventBase64}`);
      38            1 : }
      39              : 
      40            1 : function triggerFlow(state: OAuthState, eventBase64: string, deepLink: string, logger: Logger) {
      41            1 :     switch (state.flowVariant) {
      42            1 :         case "NATIVE_LINK_FLOW": {
      43            1 :             logger.info(`Opening OAuth deep link`, {}, LOG_CTX);
      44            1 :             window.open(deepLink, '_self');
      45            1 :             break;
      46              :         }
      47            1 :         case "WEB_OPENER_FLOW": {
      48              :             // COOP (Cross-Origin-Opener-Policy: same-origin) severs window.opener after
      49              :             // the popup navigates to Salesforce. Use BroadcastChannel as the primary path
      50              :             // since it works same-origin regardless of COOP, and fall back to postMessage
      51              :             // for environments that lack BroadcastChannel support.
      52            1 :             logger.info(`Posting OAuth result via broadcast channel`, {}, LOG_CTX);
      53            1 :             const channel = new BroadcastChannel(OAUTH_BROADCAST_CHANNEL);
      54            1 :             channel.postMessage(eventBase64);
      55            1 :             channel.close();
      56            1 :             if (window.opener) {
      57            1 :                 window.opener.postMessage(
      58            1 :                     { kind: APP_EVENT_POST_MESSAGE_KIND, data: eventBase64 },
      59            1 :                     window.location.origin,
      60            1 :                 );
      61              :             }
      62              :             // Close self — opener.close() can be blocked by COOP after the cross-origin
      63              :             // bounce through Salesforce, but window.close() on the popup itself works
      64              :             // because the popup was opened via window.open().
      65            1 :             window.close();
      66            1 :             break;
      67            1 :         }
      68            1 :     }
      69            1 : }
      70              : 
      71              : /// The banner with the logo and the app version.
      72              : /// A wordmark replaces both the logo and the name.
      73            1 : const OAuthBanner: React.FC = () => {
      74            1 :     const flavor = useAppFlavor();
      75            1 :     return (
      76            1 :         <div className={baseStyles.banner_container}>
      77            1 :             {!flavor.logo.wordmark && (
      78            1 :                 <div className={baseStyles.banner_logo}>
      79            1 :                     <svg width="100%" height="100%">
      80            1 :                         <use xlinkHref={`${symbols}#${flavor.logo.symbol}`} />
      81            1 :                     </svg>
      82            1 :                 </div>
      83              :             )}
      84            1 :             <div className={baseStyles.banner_text_container}>
      85            1 :                 <div className={baseStyles.banner_title}>
      86            1 :                     {flavor.logo.wordmark ? <FlavorLogo height={22} /> : flavor.displayName}
      87            1 :                 </div>
      88            1 :                 <div className={baseStyles.app_version}>version {DASHQL_VERSION}</div>
      89            1 :             </div>
      90            1 :         </div>
      91              :     );
      92            1 : };
      93              : 
      94            1 : const OAuthSucceeded: React.FC<OAuthSucceededProps> = (props: OAuthSucceededProps) => {
      95            1 :     const logger = useLogger();
      96              : 
      97            1 :     const code = props.params.get('code') ?? undefined;
      98            1 :     const oauthError = props.params.get('error') ?? undefined;
      99            1 :     const oauthErrorDescription = props.params.get('error_description') ?? undefined;
     100            1 :     const now = new Date();
     101            1 :     const [logsAreOpen, setLogsAreOpen] = React.useState<boolean>(false);
     102              : 
     103              :     // Encode the event as base64
     104            1 :     const { eventBase64, deepLink } = React.useMemo(() => {
     105            1 :         const eventMessage: AppEventData = {
     106            1 :             oauthRedirect: {
     107            1 :                 code,
     108            1 :                 error: oauthError,
     109            1 :                 errorDescription: oauthErrorDescription,
     110            1 :                 state: props.state
     111            1 :             }
     112            1 :         };
     113              :         // Encode to JSON
     114            1 :         const eventJson = JSON.stringify(eventMessage);
     115            1 :         const eventBuffer = new TextEncoder().encode(eventJson);
     116            1 :         const event = BASE64URL_CODEC.encode(eventBuffer.buffer);
     117            1 :         return {
     118            1 :             eventBase64: event,
     119            1 :             deepLink: buildDeepLink(event).toString(),
     120            1 :         }
     121            1 :     }, [code, oauthError, oauthErrorDescription, props.state]);
     122              : 
     123              :     // Setup auto-trigger
     124            1 :     const skipAutoTrigger = props.state.flowVariant == "NATIVE_LINK_FLOW" && props.state.debugMode;
     125            1 :     const autoTriggersAt = React.useMemo(() => new Date(now.getTime() + AUTO_TRIGGER_DELAY), []);
     126            1 :     const [remainingUntilAutoTrigger, setRemainingUntilAutoTrigger] = React.useState<number>(() => Math.max(autoTriggersAt.getTime(), now.getTime()) - now.getTime());
     127            1 :     const [wasTriggered, setWasTriggered] = React.useState<boolean>(false);
     128              : 
     129            1 :     React.useEffect(() => {
     130              :         // Skip auto trigger for native apps in debug mode
     131            1 :         if (skipAutoTrigger) {
     132            1 :             logger.info(`Skipping auto-trigger for native app in debug mode`, {}, LOG_CTX);
     133            1 :             return () => { };
     134            1 :         } else {
     135            1 :             logger.info("Setup auto-trigger", { "remaining": formatHHMMSS(remainingUntilAutoTrigger / 1000) }, LOG_CTX);
     136            1 :             const timeoutId = setTimeout(() => {
     137            1 :                 triggerFlow(props.state, eventBase64, deepLink, logger);
     138            1 :                 setWasTriggered(true);
     139            1 :             }, remainingUntilAutoTrigger);
     140            1 :             const updaterId: { current: unknown | null } = { current: null };
     141              : 
     142            1 :             const updateRemaining = () => {
     143            1 :                 const now = new Date();
     144            1 :                 const remainder = Math.max(autoTriggersAt.getTime(), now.getTime()) - now.getTime();
     145            1 :                 setRemainingUntilAutoTrigger(remainder);
     146            1 :                 if (remainder > AUTO_TRIGGER_COUNTER_INTERVAL) {
     147            1 :                     updaterId.current = setTimeout(updateRemaining, AUTO_TRIGGER_COUNTER_INTERVAL);
     148              :                 }
     149            1 :             };
     150            1 :             updaterId.current = setTimeout(updateRemaining, AUTO_TRIGGER_COUNTER_INTERVAL);
     151              : 
     152            1 :             return () => {
     153            1 :                 clearTimeout(timeoutId);
     154            1 :                 if (updaterId.current != null) {
     155            1 :                     clearTimeout(updaterId.current as any);
     156              :                 }
     157            1 :             }
     158            1 :         }
     159            1 :     }, [props.state, code]);
     160              : 
     161              :     // Render provider options
     162            1 :     let providerOptionsSection: React.ReactElement = <div />;
     163            1 :     let codeExpiresAt: Date | undefined = undefined;
     164              :     // eslint-disable-next-line prefer-const
     165            1 :     let [codeIsExpired, setCodeIsExpired] = React.useState(false);
     166            1 :     if (props.state.salesforceProvider) {
     167            1 :         const salesforceProvider = props.state.salesforceProvider;
     168            1 :         const expiresAt = salesforceProvider.expiresAt;
     169            1 :         codeIsExpired = now.getTime() > (expiresAt ?? 0);
     170            1 :         codeExpiresAt = codeIsExpired ? undefined : new Date(Number(expiresAt));
     171            1 :         providerOptionsSection = (
     172            1 :             <div className={baseStyles.card_section}>
     173            1 :                 <div className={baseStyles.section_entries}>
     174            1 :                     <TextField
     175            1 :                         name="Salesforce Instance URL"
     176            1 :                         value={salesforceProvider.instanceUrl}
     177            1 :                         leadingVisual={() => <div>URL</div>}
     178            1 :                         logContext={LOG_CTX}
     179            1 :                         readOnly
     180            1 :                         disabled
     181            1 :                     />
     182            1 :                     <TextField
     183            1 :                         name="Connected App"
     184            1 :                         value={salesforceProvider.appConsumerKey}
     185            1 :                         leadingVisual={() => <div>ID</div>}
     186            1 :                         logContext={LOG_CTX}
     187            1 :                         readOnly
     188            1 :                         disabled
     189            1 :                     />
     190            1 :                 </div>
     191            1 :             </div>
     192              :         );
     193            1 :     }
     194              : 
     195              :     // Determine the time we have left
     196            1 :     const remainingUntilExpiration = codeExpiresAt !== undefined
     197            1 :         ? (Math.max(codeExpiresAt.getTime(), now.getTime()) - now.getTime()) : 0;
     198            1 :     React.useEffect(() => {
     199            1 :         logger.info("Determining code expiration", { "remaining": formatHHMMSS(remainingUntilExpiration / 1000) }, LOG_CTX);
     200            1 :         const timeoutId = setTimeout(() => setCodeIsExpired(true), remainingUntilExpiration);
     201            1 :         return () => clearTimeout(timeoutId);
     202            1 :     }, [props.state]);
     203              : 
     204              :     // Get expiration validation
     205            1 :     let codeExpirationValidation: TextFieldValidationStatus;
     206            1 :     const codeIsEmpty = !code;
     207            1 :     if (oauthError) {
     208            1 :         codeExpirationValidation = {
     209            1 :             type: VALIDATION_ERROR,
     210            1 :             value: oauthErrorDescription || oauthError,
     211            1 :         };
     212            1 :     } else if (codeIsEmpty) {
     213            1 :         codeExpirationValidation = {
     214            1 :             type: VALIDATION_ERROR,
     215            1 :             value: "Code is empty"
     216            1 :         };
     217              :     } else {
     218            1 :         codeExpirationValidation = codeIsExpired ? {
     219            1 :             type: VALIDATION_ERROR,
     220            1 :             value: "Code is expired"
     221            1 :         } : {
     222            1 :             type: VALIDATION_WARNING,
     223            1 :             value: `Code expires ${(formatTimeDifference(codeExpiresAt!, now))}`
     224            1 :         };
     225              :     }
     226              : 
     227              :     // Get flow continuation
     228            1 :     let flowContinuation: React.ReactElement = <div />;
     229            1 :     switch (props.state.flowVariant) {
     230            1 :         case "WEB_OPENER_FLOW": {
     231            1 :             flowContinuation = (
     232            1 :                 <div className={baseStyles.card_section}>
     233            1 :                     <div className={baseStyles.section_entries}>
     234            1 :                         <div className={baseStyles.section_description}>
     235              :                             The authorization result was sent to the app automatically.
     236              :                             If the app did not receive it, copy the event data below and paste it into the app window.
     237            1 :                         </div>
     238            1 :                         <TextField
     239            1 :                             name="OAuth Event Data"
     240            1 :                             value={eventBase64}
     241            1 :                             leadingVisual={() => <div>Data</div>}
     242            1 :                             logContext={LOG_CTX}
     243            1 :                             readOnly
     244            1 :                             disabled
     245            1 :                             concealed
     246            1 :                         />
     247            1 :                     </div>
     248            1 :                     <div className={baseStyles.card_actions}>
     249            1 :                         <div className={baseStyles.card_actions_right}>
     250            1 :                             <CopyToClipboardButton
     251            1 :                                 variant={ButtonVariant.Default}
     252            1 :                                 size={ButtonSize.Medium}
     253            1 :                                 logContext={LOG_CTX}
     254            1 :                                 value={eventBase64}
     255            1 :                                 aria-label="copy-event-data"
     256            1 :                                 aria-labelledby=""
     257            1 :                             />
     258            1 :                         </div>
     259            1 :                     </div>
     260            1 :                 </div>
     261              :             );
     262            1 :             break;
     263              :         }
     264            1 :         case "NATIVE_LINK_FLOW": {
     265            1 :             if (props.state.debugMode) {
     266            1 :                 flowContinuation = (
     267            1 :                     <div className={baseStyles.card_section}>
     268            1 :                         <div className={baseStyles.section_entries}>
     269            1 :                             <div className={baseStyles.section_description}>
     270              :                                 The initiator is a native app in debug mode which cannot register as deep link handler.
     271              :                                 Copy the following url and paste it anywhere into the app window.
     272            1 :                             </div>
     273            1 :                             <TextField
     274            1 :                                 name="Native OAuth Callback"
     275            1 :                                 value={buildDeepLink(eventBase64).toString()}
     276            1 :                                 leadingVisual={() => <div>URL</div>}
     277            1 :                                 logContext={LOG_CTX}
     278            1 :                                 readOnly
     279            1 :                                 disabled
     280            1 :                             />
     281            1 :                         </div>
     282            1 :                     </div>
     283              :                 );
     284              : 
     285              :             } else {
     286            1 :                 flowContinuation = (
     287            1 :                     <div className={baseStyles.card_section}>
     288            1 :                         <div className={baseStyles.section_entries}>
     289            1 :                             <div className={baseStyles.section_description}>
     290              :                                 Your browser should prompt you to open the native app. You can retry until the code expires.
     291            1 :                             </div>
     292            1 :                         </div>
     293            1 :                         <div className={baseStyles.card_actions}>
     294            1 :                             <div className={baseStyles.card_actions_right}>
     295            1 :                                 <CopyToClipboardButton
     296            1 :                                     variant={ButtonVariant.Primary}
     297            1 :                                     size={ButtonSize.Medium}
     298            1 :                                     logContext={LOG_CTX}
     299            1 :                                     value={deepLink}
     300            1 :                                     aria-label="copy-deeplink"
     301            1 :                                     aria-labelledby=""
     302            1 :                                 />
     303              :                                 {
     304            1 :                                     wasTriggered
     305            1 :                                         ? <Button
     306            1 :                                             variant={ButtonVariant.Primary}
     307            1 :                                             onClick={() => triggerFlow(props.state, eventBase64, deepLink, logger)}
     308            1 :                                         >
     309              :                                             Open in App
     310            1 :                                         </Button>
     311            1 :                                         : <Button
     312            1 :                                             variant={ButtonVariant.Primary}
     313            1 :                                             onClick={() => triggerFlow(props.state, eventBase64, deepLink, logger)}
     314            1 :                                             trailingVisual={() => <div>{Math.ceil(remainingUntilAutoTrigger / 1000)}</div>}
     315            1 :                                         >
     316              :                                             Open in App
     317            1 :                                         </Button>
     318              :                                 }
     319            1 :                             </div>
     320            1 :                         </div>
     321            1 :                     </div>
     322              :                 );
     323              :             }
     324              :             break;
     325              :         }
     326            1 :     }
     327              : 
     328              :     // Construct the page
     329            1 :     return (
     330            1 :         <div className={baseStyles.page}>
     331            1 :             <div className={classNames(baseStyles.banner_and_content_container, styles.banner_and_content_container)}>
     332            1 :                 <OAuthBanner />
     333            1 :                 <div className={baseStyles.content_container}>
     334            1 :                     <div className={baseStyles.card}>
     335            1 :                         <div className={baseStyles.card_header}>
     336            1 :                             <div className={baseStyles.card_header_left_container}>
     337            1 :                                 <div className={baseStyles.card_header_left_title}>
     338            1 :                                     {oauthError ? 'Authorization Failed' : 'Authorization Succeeded'}
     339            1 :                                 </div>
     340            1 :                             </div>
     341            1 :                             <div className={baseStyles.card_header_right_container}>
     342            1 :                                 <LogsOverlay
     343            1 :                                     isOpen={logsAreOpen}
     344            1 :                                     onClose={() => setLogsAreOpen(false)}
     345            1 :                                     renderAnchor={(p: object) => (
     346            1 :                                         <IconButton
     347            1 :                                             {...p}
     348            1 :                                             variant={ButtonVariant.Invisible}
     349            1 :                                             aria-label="open-logs"
     350            1 :                                             onClick={() => setLogsAreOpen(s => !s)}
     351              :                                         >
     352            1 :                                             <svg width="16px" height="16px">
     353            1 :                                                 <use xlinkHref={`${symbols}#cpu_16`} />
     354            1 :                                             </svg>
     355            1 :                                         </IconButton>
     356              :                                     )}
     357            1 :                                     side={AnchorSide.OutsideBottom}
     358            1 :                                     align={AnchorAlignment.End}
     359            1 :                                     anchorOffset={16}
     360            1 :                                 />
     361            1 :                             </div>
     362            1 :                         </div>
     363            1 :                         {providerOptionsSection}
     364            1 :                         <div className={baseStyles.card_section}>
     365            1 :                             <div className={baseStyles.section_entries}>
     366            1 :                                 <TextField
     367            1 :                                     name="Authorization Code"
     368            1 :                                     value={code ?? ""}
     369            1 :                                     leadingVisual={() => <div>Code</div>}
     370            1 :                                     validation={codeExpirationValidation}
     371            1 :                                     logContext={LOG_CTX}
     372            1 :                                     readOnly
     373            1 :                                     disabled
     374            1 :                                     concealed
     375            1 :                                 />
     376            1 :                             </div>
     377            1 :                         </div>
     378            1 :                         {flowContinuation}
     379            1 :                     </div>
     380            1 :                 </div>
     381            1 :             </div>
     382            1 :         </div>
     383              :     );
     384            1 : }
     385              : 
     386              : interface OAuthFailedProps {
     387              :     error: Error;
     388              : }
     389              : 
     390            1 : const OAuthFailed: React.FC<OAuthFailedProps> = (props: OAuthFailedProps) => {
     391            1 :     return (
     392            1 :         <div className={baseStyles.page}>
     393            1 :             <OAuthBanner />
     394            1 :             <div className={baseStyles.card}>
     395            1 :                 <div className={baseStyles.card_header}>
     396            1 :                     <div>Authorization Failed</div>
     397            1 :                 </div>
     398            1 :                 {stringifyError(props.error)}
     399            1 :             </div>
     400            1 :         </div>
     401              :     );
     402            1 : }
     403              : 
     404              : 
     405              : /// What the page needs from the location of the window, tests replace it
     406              : export interface RedirectPageDependencies {
     407              :     /// The url of the page
     408              :     currentUrl: () => URL;
     409              :     /// Leave the page for another url
     410              :     replaceLocation: (url: string) => void;
     411              : }
     412              : 
     413            1 : const DEFAULT_DEPENDENCIES: RedirectPageDependencies = {
     414            1 :     currentUrl: () => new URL(window.location.href),
     415            1 :     replaceLocation: url => window.location.replace(url),
     416            1 : };
     417              : 
     418              : interface RedirectPageProps {
     419              :     dependencies?: RedirectPageDependencies;
     420              : }
     421              : 
     422            1 : export const RedirectPage: React.FC<RedirectPageProps> = (props: RedirectPageProps) => {
     423            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
     424            1 :     const [params, _setParams] = useSearchParams();
     425            1 :     const state = params.get("state") ?? "";
     426              : 
     427            1 :     const authState = React.useMemo<Result<OAuthState>>(() => {
     428            1 :         try {
     429            1 :             const parsed = decodeOAuthState(state);
     430            1 :             validateOAuthRedirectState(parsed);
     431            1 :             return {
     432            1 :                 type: RESULT_OK,
     433            1 :                 value: parsed,
     434            1 :             };
     435            1 :         } catch (e: any) {
     436            1 :             return {
     437            1 :                 type: RESULT_ERROR,
     438            1 :                 error: new Error(stringifyError(e)),
     439            1 :             };
     440            1 :         }
     441            1 :     }, [state]);
     442              : 
     443              :     // If the state encodes a callbackUrl on a different origin, redirect the popup there
     444              :     // before doing anything else. This lets localhost dev servers receive the OAuth code
     445              :     // via BroadcastChannel even though the registered redirect_uri is dashql.app/oauth.html.
     446            1 :     if (authState.type == RESULT_OK) {
     447            1 :         const callbackUrl = authState.value.callbackUrl;
     448            1 :         if (callbackUrl) {
     449            1 :             const currentUrl = dependencies.currentUrl();
     450            1 :             if (!isAllowedOAuthCallbackUrl(callbackUrl, currentUrl)) {
     451            1 :                 return <OAuthFailed error={new Error('OAuth callback URL is not allowed')} />;
     452              :             }
     453            1 :             const target = new URL(callbackUrl);
     454            1 :             if (target.origin !== currentUrl.origin) {
     455              :                 // Forward all query params (code, state, error, ...) to the target origin
     456            1 :                 target.search = currentUrl.search;
     457            1 :                 dependencies.replaceLocation(target.toString());
     458            1 :                 return <div />;
     459            1 :             }
     460            1 :         }
     461            1 :     }
     462              : 
     463            1 :     if (authState.type == RESULT_OK) {
     464            1 :         return <OAuthSucceeded params={params} state={authState.value} />
     465              :     } else {
     466            1 :         return <OAuthFailed error={authState.error} />
     467              :     }
     468            1 : };
        

Generated by: LCOV version 2.0-1