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