Skip to content

Commit 63a35e1

Browse files
author
Darcy Ye
authored
feat: add GOT callback handler (#1166)
* feat: add GOT callback handler * fix: fix CSP, refactor GOT credential verifier * chore: test GET verify API * chore: use otp landing page * chore: add frame-ancestor config * chore: pop up fallback mechanism * chore: redirect in-place and try POST * chore: test without CSP headers for CF * chore: test experience google credential * chore: add debug log and check/grant storage access * chore: update console landing page
1 parent 1a3e917 commit 63a35e1

9 files changed

Lines changed: 497 additions & 53 deletions

File tree

src/theme/Layout/GoogleOneTapInitializer.tsx

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,70 @@
1-
import { type ReactNode, useEffect } from 'react';
1+
import { type ReactNode, useCallback, useEffect } from 'react';
22

33
import type { DebugLogger } from './debug-logger';
44
import type { GoogleOneTapConfig } from './google-one-tap';
5-
6-
type GoogleCredentialResponse = {
7-
credential: string;
8-
};
5+
import { useApiBaseUrl, useGoogleOneTapVerify } from './hooks';
6+
import type {
7+
SiteConfig,
8+
GoogleOneTapCredentialResponse,
9+
GoogleOneTapVerifyResponse,
10+
} from './types';
11+
import { appendPath } from '@silverhand/essentials';
912

1013
type GoogleOneTapInitializerProps = {
1114
readonly config: GoogleOneTapConfig;
1215
readonly debugLogger: DebugLogger;
16+
readonly siteConfig: SiteConfig;
1317
};
1418

1519
export default function GoogleOneTapInitializer({
1620
config,
1721
debugLogger,
22+
siteConfig,
1823
}: GoogleOneTapInitializerProps): ReactNode {
19-
useEffect(() => {
20-
// Define global handleCredentialResponse function
21-
// eslint-disable-next-line @silverhand/fp/no-mutation
22-
window.handleCredentialResponse = (response: GoogleCredentialResponse) => {
23-
console.log('Encoded JWT ID token:', response.credential);
24-
// TODO: Send to your backend for verification
25-
};
26-
}, []);
24+
const { logtoAdminConsoleUrl } = useApiBaseUrl(siteConfig);
25+
26+
// Function to manually build Logto sign-in URL
27+
const buildSignInUrl = useCallback(
28+
({ credential }: GoogleOneTapVerifyResponse) => {
29+
try {
30+
if (!logtoAdminConsoleUrl) {
31+
throw new Error('Logto admin console URL is not set');
32+
}
33+
34+
const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'external-google-one-tap'));
35+
36+
signInUrl.searchParams.set('credential', credential);
37+
38+
return signInUrl.toString();
39+
} catch (error) {
40+
debugLogger.error('Failed to build sign-in URL:', error);
41+
return null;
42+
}
43+
},
44+
[logtoAdminConsoleUrl, debugLogger]
45+
);
46+
47+
const handleCredentialResponse = useCallback(
48+
async (response: GoogleOneTapCredentialResponse) => {
49+
debugLogger.log('handleCredentialResponse received response:', response);
50+
51+
try {
52+
// Build Logto sign-in URL with one-time token
53+
const signInUrl = buildSignInUrl(response);
54+
55+
if (signInUrl) {
56+
// Directly navigate to sign-in URL in current window
57+
window.location.href = signInUrl;
58+
debugLogger.log('Redirecting to Logto sign-in URL', signInUrl);
59+
} else {
60+
debugLogger.error('Failed to build sign-in URL');
61+
}
62+
} catch (error) {
63+
debugLogger.error('Failed to open sign-in URL:', error);
64+
}
65+
},
66+
[debugLogger, buildSignInUrl]
67+
);
2768

2869
useEffect(() => {
2970
if (config.oneTap?.isEnabled && window.google?.accounts.id) {
@@ -34,7 +75,7 @@ export default function GoogleOneTapInitializer({
3475
window.google.accounts.id.initialize({
3576
client_id: config.clientId,
3677
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
37-
callback: window.handleCredentialResponse!,
78+
callback: handleCredentialResponse,
3879
auto_select: config.oneTap.autoSelect,
3980
cancel_on_tap_outside: config.oneTap.closeOnTapOutside,
4081
itp_support: config.oneTap.itpSupport,

src/theme/Layout/auth-status.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
debugIframeTimeoutDelay,
77
} from './constants';
88
import type { DebugLogger } from './debug-logger';
9+
import { createStorageAccessChecker } from './storage-access';
910
import { AuthMessageType, type AuthStatusRequest, type AuthStatusResponse } from './types';
1011

1112
export type AuthStatusCheckerOptions = {
@@ -36,6 +37,12 @@ export function createAuthStatusChecker({
3637
const authStatusCheckerHost =
3738
typeof logtoAdminConsoleUrl === 'string' ? new URL(logtoAdminConsoleUrl).origin : undefined;
3839

40+
const { checkStorageAccess, requestStorageAccess } = createStorageAccessChecker({
41+
logtoAdminConsoleUrl,
42+
enableAuthStatusCheck,
43+
debugLogger,
44+
});
45+
3946
/**
4047
* Function to check admin token status via cross-domain iframe communication
4148
*
@@ -46,6 +53,27 @@ export function createAuthStatusChecker({
4653
* @throws Error if auth status checker is not configured or request fails
4754
*/
4855
const checkAdminTokenStatus = async (): Promise<boolean> => {
56+
try {
57+
debugLogger.log('Checking storage access before admin token check');
58+
59+
const hasStorageAccess = await checkStorageAccess();
60+
debugLogger.log('Storage access check result:', hasStorageAccess);
61+
62+
if (!hasStorageAccess) {
63+
debugLogger.log('Storage access not available, requesting access');
64+
const storageAccessGranted = await requestStorageAccess();
65+
debugLogger.log('Storage access request result:', storageAccessGranted);
66+
67+
if (!storageAccessGranted) {
68+
throw new Error('Storage access required but not granted');
69+
}
70+
}
71+
72+
debugLogger.log('Storage access confirmed, proceeding with admin token check');
73+
} catch (error) {
74+
debugLogger.warn('Storage access check/request failed:', error);
75+
}
76+
4977
return new Promise((resolve, reject) => {
5078
if (typeof document === 'undefined') {
5179
reject(new Error('Document not available (SSR environment)'));
@@ -59,6 +87,7 @@ export function createAuthStatusChecker({
5987

6088
const iframe = document.createElement('iframe');
6189
iframe.src = iframeSrc;
90+
iframe.sandbox = 'allow-scripts allow-same-origin allow-storage-access-by-user-activation';
6291

6392
if (isIframeVisible) {
6493
// Temporarily show iframe for debugging
@@ -232,6 +261,8 @@ export function createAuthStatusChecker({
232261

233262
return {
234263
checkAdminTokenStatus,
264+
checkStorageAccess,
265+
requestStorageAccess,
235266
authStatusCheckerHost,
236267
iframeSrc,
237268
};

src/theme/Layout/config-fetcher.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,7 @@ export async function fetchGoogleOneTapConfig({
2424
}
2525
}
2626

27-
const response = await fetch(`${apiBaseUrl}/api/google-one-tap/config`, {
28-
headers: {
29-
Origin: window.location.origin,
30-
},
31-
});
27+
const response = await fetch(`${apiBaseUrl}/api/google-one-tap/config`);
3228

3329
if (!response.ok) {
3430
throw new Error('Failed to fetch Google One Tap config');
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { DebugLogger } from './debug-logger';
2+
import type { GoogleOneTapCredentialResponse, GoogleOneTapVerifyResponse } from './types';
3+
4+
export type CredentialVerifierOptions = {
5+
apiBaseUrl: string;
6+
debugLogger: DebugLogger;
7+
};
8+
9+
export async function verifyGoogleOneTapCredential(
10+
{ apiBaseUrl, debugLogger }: CredentialVerifierOptions,
11+
response: GoogleOneTapCredentialResponse
12+
): Promise<GoogleOneTapVerifyResponse | undefined> {
13+
debugLogger.log('Google One Tap credential response received:', response);
14+
15+
try {
16+
const verifyResponse = await fetch(`${apiBaseUrl}/api/google-one-tap/verify`, {
17+
method: 'POST',
18+
headers: {
19+
'Content-Type': 'application/json',
20+
},
21+
body: JSON.stringify({ idToken: response.credential }),
22+
});
23+
24+
if (!verifyResponse.ok) {
25+
throw new Error(`Verification failed: ${verifyResponse.status}`);
26+
}
27+
28+
const data = await verifyResponse.json();
29+
debugLogger.log('Google One Tap verification successful:', data);
30+
31+
// eslint-disable-next-line no-restricted-syntax
32+
return data as GoogleOneTapVerifyResponse;
33+
} catch (error) {
34+
debugLogger.error('Google One Tap verification failed:', error);
35+
return undefined;
36+
}
37+
}

src/theme/Layout/hooks.ts

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @silverhand/fp/no-mutation */
2-
import { condString } from '@silverhand/essentials';
2+
import { condString, type Optional } from '@silverhand/essentials';
33
import { useCallback, useEffect, useMemo, useState } from 'react';
44

55
import { createAuthStatusChecker } from './auth-status';
@@ -13,31 +13,55 @@ import {
1313
initialAuthCheckDelay,
1414
authCheckFallbackTimeout,
1515
} from './constants';
16+
import { verifyGoogleOneTapCredential } from './credential-verifier';
1617
import { createDebugLogger, type DebugLogger } from './debug-logger';
1718
import type { GoogleOneTapConfig } from './google-one-tap';
18-
import type { SiteConfig } from './types';
19+
import type {
20+
SiteConfig,
21+
GoogleOneTapCredentialResponse,
22+
GoogleOneTapVerifyResponse,
23+
} from './types';
1924

2025
export function useDebugLogger(siteConfig: SiteConfig): DebugLogger {
2126
const isDebugMode = Boolean(siteConfig.customFields?.isDebuggingEnabled);
2227

2328
return useMemo(() => createDebugLogger(isDebugMode), [isDebugMode]);
2429
}
2530

26-
export function useApiBaseUrl(siteConfig: SiteConfig): string {
31+
export function useApiBaseUrl(siteConfig: SiteConfig): {
32+
baseUrl: string;
33+
authUrl: string;
34+
redirectUri: string;
35+
logtoAdminConsoleUrl?: string;
36+
} {
2737
return useMemo(() => {
2838
const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl;
29-
return typeof logtoApiBaseUrl === 'string'
30-
? logtoApiBaseUrl
31-
: siteConfig.customFields?.isDevFeatureEnabled
32-
? defaultApiBaseDevUrl
33-
: defaultApiBaseProdUrl;
34-
}, [siteConfig.customFields?.logtoApiBaseUrl, siteConfig.customFields?.isDevFeatureEnabled]);
39+
const baseUrl =
40+
typeof logtoApiBaseUrl === 'string'
41+
? logtoApiBaseUrl
42+
: siteConfig.customFields?.isDevFeatureEnabled
43+
? defaultApiBaseDevUrl
44+
: defaultApiBaseProdUrl;
45+
const authUrl = `${baseUrl}/oidc/auth`;
46+
const redirectUri = `${typeof logtoApiBaseUrl === 'string' ? `${logtoApiBaseUrl}/${new URL(logtoApiBaseUrl).hostname === 'localhost' ? 'demo-app' : 'callback'}` : `${defaultApiBaseProdUrl}/callback`}`;
47+
const logtoAdminConsoleUrl = siteConfig.customFields?.logtoAdminConsoleUrl;
48+
return {
49+
baseUrl,
50+
authUrl,
51+
redirectUri,
52+
logtoAdminConsoleUrl,
53+
};
54+
}, [
55+
siteConfig.customFields?.logtoApiBaseUrl,
56+
siteConfig.customFields?.isDevFeatureEnabled,
57+
siteConfig.customFields?.logtoAdminConsoleUrl,
58+
]);
3559
}
3660

3761
export function useGoogleOneTapConfig(
3862
apiBaseUrl: string,
3963
debugLogger: DebugLogger
40-
): GoogleOneTapConfig | undefined {
64+
): Optional<GoogleOneTapConfig> {
4165
const [config, setConfig] = useState<GoogleOneTapConfig>();
4266

4367
useEffect(() => {
@@ -65,10 +89,24 @@ export function useGoogleOneTapConfig(
6589
return config;
6690
}
6791

92+
export function useGoogleOneTapVerify(
93+
apiBaseUrl: string,
94+
debugLogger: DebugLogger
95+
): (response: GoogleOneTapCredentialResponse) => Promise<Optional<GoogleOneTapVerifyResponse>> {
96+
return useCallback(
97+
async (response: GoogleOneTapCredentialResponse) => {
98+
return verifyGoogleOneTapCredential({ apiBaseUrl, debugLogger }, response);
99+
},
100+
[apiBaseUrl, debugLogger]
101+
);
102+
}
103+
68104
export type AuthStatusResult = {
69105
authStatus?: boolean;
70106
authCheckError?: string;
71107
checkAdminTokenStatus: () => Promise<boolean>;
108+
checkStorageAccess: () => Promise<boolean>;
109+
requestStorageAccess: () => Promise<boolean>;
72110
};
73111

74112
export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger): AuthStatusResult {
@@ -81,18 +119,19 @@ export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger):
81119
const isDebugMode = Boolean(siteConfig.customFields?.isDebuggingEnabled);
82120
const isIframeVisible = Boolean(siteConfig.customFields?.isIframeVisible);
83121

84-
const { checkAdminTokenStatus, authStatusCheckerHost } = useMemo(
85-
() =>
86-
createAuthStatusChecker({
87-
logtoAdminConsoleUrl:
88-
typeof logtoAdminConsoleUrl === 'string' ? logtoAdminConsoleUrl : undefined,
89-
enableAuthStatusCheck: Boolean(enableAuthStatusCheck),
90-
isDebugMode,
91-
isIframeVisible,
92-
debugLogger,
93-
}),
94-
[logtoAdminConsoleUrl, enableAuthStatusCheck, isDebugMode, isIframeVisible, debugLogger]
95-
);
122+
const { checkAdminTokenStatus, checkStorageAccess, requestStorageAccess, authStatusCheckerHost } =
123+
useMemo(
124+
() =>
125+
createAuthStatusChecker({
126+
logtoAdminConsoleUrl:
127+
typeof logtoAdminConsoleUrl === 'string' ? logtoAdminConsoleUrl : undefined,
128+
enableAuthStatusCheck: Boolean(enableAuthStatusCheck),
129+
isDebugMode,
130+
isIframeVisible,
131+
debugLogger,
132+
}),
133+
[logtoAdminConsoleUrl, enableAuthStatusCheck, isDebugMode, isIframeVisible, debugLogger]
134+
);
96135

97136
const performAuthCheckWithRetry = useCallback(
98137
async (retryCount = 0): Promise<void> => {
@@ -185,9 +224,11 @@ export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger):
185224
authStatus,
186225
authCheckError,
187226
checkAdminTokenStatus,
227+
checkStorageAccess,
228+
requestStorageAccess,
188229
};
189230
}
190-
}, [authStatus, authCheckError, checkAdminTokenStatus]);
231+
}, [authStatus, authCheckError, checkAdminTokenStatus, checkStorageAccess, requestStorageAccess]);
191232

192233
// Debug logging
193234
useEffect(() => {
@@ -219,6 +260,8 @@ export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger):
219260
authStatus,
220261
authCheckError,
221262
checkAdminTokenStatus,
263+
checkStorageAccess,
264+
requestStorageAccess,
222265
};
223266
}
224267
/* eslint-enable @silverhand/fp/no-mutation */

src/theme/Layout/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
1414
// Hooks must be called at the top level, outside of try-catch
1515
const { siteConfig } = useDocusaurusContext();
1616
const debugLogger = useDebugLogger(siteConfig);
17-
const apiBaseUrl = useApiBaseUrl(siteConfig);
17+
const { baseUrl: apiBaseUrl } = useApiBaseUrl(siteConfig);
1818
const config = useGoogleOneTapConfig(apiBaseUrl, debugLogger);
1919
const { authStatus } = useAuthStatus(siteConfig, debugLogger);
2020

@@ -28,7 +28,13 @@ export default function LayoutWrapper(props: Props): ReactNode {
2828
<Layout {...props} />
2929
{authStatus === false && config?.oneTap?.isEnabled && (
3030
<BrowserOnly fallback={<div>Loading Google Sign-In...</div>}>
31-
{() => <GoogleOneTapInitializer config={config} debugLogger={debugLogger} />}
31+
{() => (
32+
<GoogleOneTapInitializer
33+
config={config}
34+
debugLogger={debugLogger}
35+
siteConfig={siteConfig}
36+
/>
37+
)}
3238
</BrowserOnly>
3339
)}
3440
</>

0 commit comments

Comments
 (0)