From e5659651f8358b96f9ee5e98f2c24ea2f8208dab Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 23 Jun 2025 23:18:49 +0800 Subject: [PATCH 01/11] feat: add GOT callback handler --- src/theme/Layout/GoogleOneTapInitializer.tsx | 78 ++++++++++++++++---- src/theme/Layout/hooks.ts | 71 +++++++++++++++--- src/theme/Layout/index.tsx | 10 ++- src/theme/Layout/types.ts | 10 +++ 4 files changed, 144 insertions(+), 25 deletions(-) diff --git a/src/theme/Layout/GoogleOneTapInitializer.tsx b/src/theme/Layout/GoogleOneTapInitializer.tsx index 3a51efc4970..38ef31a1bf4 100644 --- a/src/theme/Layout/GoogleOneTapInitializer.tsx +++ b/src/theme/Layout/GoogleOneTapInitializer.tsx @@ -1,29 +1,79 @@ -import { type ReactNode, useEffect } from 'react'; +import { type ReactNode, useCallback, useEffect } from 'react'; import type { DebugLogger } from './debug-logger'; import type { GoogleOneTapConfig } from './google-one-tap'; - -type GoogleCredentialResponse = { - credential: string; -}; +import { useApiBaseUrl, useGoogleOneTapVerify } from './hooks'; +import type { + SiteConfig, + GoogleOneTapCredentialResponse, + GoogleOneTapVerifyResponse, +} from './types'; type GoogleOneTapInitializerProps = { readonly config: GoogleOneTapConfig; readonly debugLogger: DebugLogger; + readonly siteConfig: SiteConfig; }; export default function GoogleOneTapInitializer({ config, debugLogger, + siteConfig, }: GoogleOneTapInitializerProps): ReactNode { - useEffect(() => { - // Define global handleCredentialResponse function - // eslint-disable-next-line @silverhand/fp/no-mutation - window.handleCredentialResponse = (response: GoogleCredentialResponse) => { - console.log('Encoded JWT ID token:', response.credential); - // TODO: Send to your backend for verification - }; - }, []); + const { baseUrl: apiBaseUrl, authUrl, redirectUri } = useApiBaseUrl(siteConfig); + const verifyGoogleOneTap = useGoogleOneTapVerify(apiBaseUrl, debugLogger); + + // Function to manually build Logto sign-in URL + const buildSignInUrl = useCallback( + ({ oneTimeToken, email, isNewUser }: GoogleOneTapVerifyResponse) => { + try { + const signInUrl = new URL(authUrl); + + // Standard OIDC parameters: client_id + signInUrl.searchParams.set('client_id', 'admin-console'); + signInUrl.searchParams.set('redirect_uri', redirectUri); + signInUrl.searchParams.set('first_screen', isNewUser ? 'register' : 'sign_in'); + + // Add one-time token parameters + signInUrl.searchParams.set('one_time_token', oneTimeToken); + signInUrl.searchParams.set('login_hint', email); + + return signInUrl.toString(); + } catch (error) { + debugLogger.error('Failed to build sign-in URL:', error); + return null; + } + }, + [authUrl, redirectUri, debugLogger] + ); + + const handleCredentialResponse = useCallback( + async (response: GoogleOneTapCredentialResponse) => { + debugLogger.log('handleCredentialResponse received response:', response); + + const verifyData = await verifyGoogleOneTap(response); + + if (verifyData) { + debugLogger.log('Verification completed:', verifyData); + + try { + // Build Logto sign-in URL with one-time token + const signInUrl = buildSignInUrl(verifyData); + + if (signInUrl) { + // Open sign-in URL in new tab + window.open(signInUrl, '_blank', 'noopener,noreferrer'); + debugLogger.log('Logto sign-in URL opened in new tab with one-time token'); + } else { + debugLogger.error('Failed to build sign-in URL'); + } + } catch (error) { + debugLogger.error('Failed to open sign-in URL:', error); + } + } + }, + [verifyGoogleOneTap, debugLogger, buildSignInUrl] + ); useEffect(() => { if (config.oneTap?.isEnabled && window.google?.accounts.id) { @@ -34,7 +84,7 @@ export default function GoogleOneTapInitializer({ window.google.accounts.id.initialize({ client_id: config.clientId, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - callback: window.handleCredentialResponse!, + callback: handleCredentialResponse, auto_select: config.oneTap.autoSelect, cancel_on_tap_outside: config.oneTap.closeOnTapOutside, itp_support: config.oneTap.itpSupport, diff --git a/src/theme/Layout/hooks.ts b/src/theme/Layout/hooks.ts index 60449b5dd32..b0b2870742f 100644 --- a/src/theme/Layout/hooks.ts +++ b/src/theme/Layout/hooks.ts @@ -1,5 +1,5 @@ /* eslint-disable @silverhand/fp/no-mutation */ -import { condString } from '@silverhand/essentials'; +import { condString, type Optional } from '@silverhand/essentials'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { createAuthStatusChecker } from './auth-status'; @@ -15,7 +15,11 @@ import { } from './constants'; import { createDebugLogger, type DebugLogger } from './debug-logger'; import type { GoogleOneTapConfig } from './google-one-tap'; -import type { SiteConfig } from './types'; +import type { + SiteConfig, + GoogleOneTapCredentialResponse, + GoogleOneTapVerifyResponse, +} from './types'; export function useDebugLogger(siteConfig: SiteConfig): DebugLogger { const isDebugMode = Boolean(siteConfig.customFields?.isDebuggingEnabled); @@ -23,21 +27,33 @@ export function useDebugLogger(siteConfig: SiteConfig): DebugLogger { return useMemo(() => createDebugLogger(isDebugMode), [isDebugMode]); } -export function useApiBaseUrl(siteConfig: SiteConfig): string { +export function useApiBaseUrl(siteConfig: SiteConfig): { + baseUrl: string; + authUrl: string; + redirectUri: string; +} { return useMemo(() => { const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl; - return typeof logtoApiBaseUrl === 'string' - ? logtoApiBaseUrl - : siteConfig.customFields?.isDevFeatureEnabled - ? defaultApiBaseDevUrl - : defaultApiBaseProdUrl; + const baseUrl = + typeof logtoApiBaseUrl === 'string' + ? logtoApiBaseUrl + : siteConfig.customFields?.isDevFeatureEnabled + ? defaultApiBaseDevUrl + : defaultApiBaseProdUrl; + const authUrl = `${baseUrl}/oidc/auth`; + const redirectUri = `${typeof logtoApiBaseUrl === 'string' ? `${logtoApiBaseUrl}/${new URL(logtoApiBaseUrl).hostname === 'localhost' ? 'demo-app' : 'callback'}` : `${defaultApiBaseProdUrl}/callback`}`; + return { + baseUrl, + authUrl, + redirectUri, + }; }, [siteConfig.customFields?.logtoApiBaseUrl, siteConfig.customFields?.isDevFeatureEnabled]); } export function useGoogleOneTapConfig( apiBaseUrl: string, debugLogger: DebugLogger -): GoogleOneTapConfig | undefined { +): Optional { const [config, setConfig] = useState(); useEffect(() => { @@ -65,6 +81,43 @@ export function useGoogleOneTapConfig( return config; } +export function useGoogleOneTapVerify( + apiBaseUrl: string, + debugLogger: DebugLogger +): (response: GoogleOneTapCredentialResponse) => Promise> { + return useCallback( + async (response: GoogleOneTapCredentialResponse) => { + debugLogger.log('Google One Tap credential response received:', response); + + try { + const verifyResponse = await fetch(`${apiBaseUrl}/api/google-one-tap/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: window.location.origin, + }, + body: JSON.stringify({ + idToken: response.credential, + }), + }); + + if (!verifyResponse.ok) { + throw new Error(`Verification failed: ${verifyResponse.status}`); + } + + const data = await verifyResponse.json(); + debugLogger.log('Google One Tap verification successful:', data); + + // eslint-disable-next-line no-restricted-syntax + return data as GoogleOneTapVerifyResponse; + } catch (error) { + debugLogger.error('Google One Tap verification failed:', error); + } + }, + [apiBaseUrl, debugLogger] + ); +} + export type AuthStatusResult = { authStatus?: boolean; authCheckError?: string; diff --git a/src/theme/Layout/index.tsx b/src/theme/Layout/index.tsx index 732b9c3d72e..69681d0b3b1 100644 --- a/src/theme/Layout/index.tsx +++ b/src/theme/Layout/index.tsx @@ -14,7 +14,7 @@ export default function LayoutWrapper(props: Props): ReactNode { // Hooks must be called at the top level, outside of try-catch const { siteConfig } = useDocusaurusContext(); const debugLogger = useDebugLogger(siteConfig); - const apiBaseUrl = useApiBaseUrl(siteConfig); + const { baseUrl: apiBaseUrl } = useApiBaseUrl(siteConfig); const config = useGoogleOneTapConfig(apiBaseUrl, debugLogger); const { authStatus } = useAuthStatus(siteConfig, debugLogger); @@ -28,7 +28,13 @@ export default function LayoutWrapper(props: Props): ReactNode { {authStatus === false && config?.oneTap?.isEnabled && ( Loading Google Sign-In...}> - {() => } + {() => ( + + )} )} diff --git a/src/theme/Layout/types.ts b/src/theme/Layout/types.ts index dddafeb8eaa..fa70c1037f6 100644 --- a/src/theme/Layout/types.ts +++ b/src/theme/Layout/types.ts @@ -27,6 +27,16 @@ export type SiteConfig = { }; }; +export type GoogleOneTapCredentialResponse = { + credential: string; +}; + +export type GoogleOneTapVerifyResponse = { + oneTimeToken: string; + isNewUser: boolean; + email: string; +}; + export type AuthStatusGlobal = { authStatus: boolean | undefined; authCheckError: string | undefined; From 84364e76bd1758cc1f719c9dbf0590bd84002b3f Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 1 Jul 2025 15:34:28 +0800 Subject: [PATCH 02/11] fix: fix CSP, refactor GOT credential verifier --- src/theme/Layout/credential-verifier.ts | 40 +++++++++++++++++++++++++ src/theme/Layout/hooks.ts | 28 ++--------------- static/_headers | 2 +- 3 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 src/theme/Layout/credential-verifier.ts diff --git a/src/theme/Layout/credential-verifier.ts b/src/theme/Layout/credential-verifier.ts new file mode 100644 index 00000000000..c665c6450fd --- /dev/null +++ b/src/theme/Layout/credential-verifier.ts @@ -0,0 +1,40 @@ +import type { DebugLogger } from './debug-logger'; +import type { GoogleOneTapCredentialResponse, GoogleOneTapVerifyResponse } from './types'; + +export type CredentialVerifierOptions = { + apiBaseUrl: string; + debugLogger: DebugLogger; +}; + +export async function verifyGoogleOneTapCredential( + { apiBaseUrl, debugLogger }: CredentialVerifierOptions, + response: GoogleOneTapCredentialResponse +): Promise { + debugLogger.log('Google One Tap credential response received:', response); + + try { + const verifyResponse = await fetch(`${apiBaseUrl}/api/google-one-tap/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: window.location.origin, + }, + body: JSON.stringify({ + idToken: response.credential, + }), + }); + + if (!verifyResponse.ok) { + throw new Error(`Verification failed: ${verifyResponse.status}`); + } + + const data = await verifyResponse.json(); + debugLogger.log('Google One Tap verification successful:', data); + + // eslint-disable-next-line no-restricted-syntax + return data as GoogleOneTapVerifyResponse; + } catch (error) { + debugLogger.error('Google One Tap verification failed:', error); + return undefined; + } +} diff --git a/src/theme/Layout/hooks.ts b/src/theme/Layout/hooks.ts index b0b2870742f..15ae1886c2b 100644 --- a/src/theme/Layout/hooks.ts +++ b/src/theme/Layout/hooks.ts @@ -13,6 +13,7 @@ import { initialAuthCheckDelay, authCheckFallbackTimeout, } from './constants'; +import { verifyGoogleOneTapCredential } from './credential-verifier'; import { createDebugLogger, type DebugLogger } from './debug-logger'; import type { GoogleOneTapConfig } from './google-one-tap'; import type { @@ -87,32 +88,7 @@ export function useGoogleOneTapVerify( ): (response: GoogleOneTapCredentialResponse) => Promise> { return useCallback( async (response: GoogleOneTapCredentialResponse) => { - debugLogger.log('Google One Tap credential response received:', response); - - try { - const verifyResponse = await fetch(`${apiBaseUrl}/api/google-one-tap/verify`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Origin: window.location.origin, - }, - body: JSON.stringify({ - idToken: response.credential, - }), - }); - - if (!verifyResponse.ok) { - throw new Error(`Verification failed: ${verifyResponse.status}`); - } - - const data = await verifyResponse.json(); - debugLogger.log('Google One Tap verification successful:', data); - - // eslint-disable-next-line no-restricted-syntax - return data as GoogleOneTapVerifyResponse; - } catch (error) { - debugLogger.error('Google One Tap verification failed:', error); - } + return verifyGoogleOneTapCredential({ apiBaseUrl, debugLogger }, response); }, [apiBaseUrl, debugLogger] ); diff --git a/static/_headers b/static/_headers index 6d9ccd935ad..dda873bb9be 100644 --- a/static/_headers +++ b/static/_headers @@ -14,4 +14,4 @@ # - connect-src: Allows network requests to Google's authentication endpoints # - style-src: Allows Google's CSS and inline styles # - font-src: Allows fonts from CDN, Google Fonts, and data URIs - Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://apis.google.com https://www.gstatic.com https://akasha.logto.io https://cdn.jsdelivr.net; img-src 'self' data: https: https://accounts.google.com https://lh3.googleusercontent.com; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://akasha.logto.io https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com/css2; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.googleapis.com/css2; object-src 'none'; base-uri 'self'; frame-src 'self' https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; + Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://apis.google.com https://www.gstatic.com https://akasha.logto.io https://cdn.jsdelivr.net; img-src 'self' data: https: https://accounts.google.com https://lh3.googleusercontent.com; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://akasha.logto.io https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com/css2 https://accounts.google.com/gsi/style; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.googleapis.com/css2; object-src 'none'; base-uri 'self'; frame-src 'self' https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; From bbaf685427a8546e996212631b6c8aa27a7b9aa9 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Wed, 2 Jul 2025 12:12:44 +0800 Subject: [PATCH 03/11] chore: test GET verify API --- src/theme/Layout/GoogleOneTapInitializer.tsx | 2 +- src/theme/Layout/config-fetcher.ts | 6 +----- src/theme/Layout/credential-verifier.ts | 14 ++++---------- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/theme/Layout/GoogleOneTapInitializer.tsx b/src/theme/Layout/GoogleOneTapInitializer.tsx index 38ef31a1bf4..d3f706ffd74 100644 --- a/src/theme/Layout/GoogleOneTapInitializer.tsx +++ b/src/theme/Layout/GoogleOneTapInitializer.tsx @@ -63,7 +63,7 @@ export default function GoogleOneTapInitializer({ if (signInUrl) { // Open sign-in URL in new tab window.open(signInUrl, '_blank', 'noopener,noreferrer'); - debugLogger.log('Logto sign-in URL opened in new tab with one-time token'); + debugLogger.log('Logto sign-in URL opened in new tab with one-time token', signInUrl); } else { debugLogger.error('Failed to build sign-in URL'); } diff --git a/src/theme/Layout/config-fetcher.ts b/src/theme/Layout/config-fetcher.ts index 418d582af38..3bad150a09b 100644 --- a/src/theme/Layout/config-fetcher.ts +++ b/src/theme/Layout/config-fetcher.ts @@ -24,11 +24,7 @@ export async function fetchGoogleOneTapConfig({ } } - const response = await fetch(`${apiBaseUrl}/api/google-one-tap/config`, { - headers: { - Origin: window.location.origin, - }, - }); + const response = await fetch(`${apiBaseUrl}/api/google-one-tap/config`); if (!response.ok) { throw new Error('Failed to fetch Google One Tap config'); diff --git a/src/theme/Layout/credential-verifier.ts b/src/theme/Layout/credential-verifier.ts index c665c6450fd..3b3a35fc1cf 100644 --- a/src/theme/Layout/credential-verifier.ts +++ b/src/theme/Layout/credential-verifier.ts @@ -13,16 +13,10 @@ export async function verifyGoogleOneTapCredential( debugLogger.log('Google One Tap credential response received:', response); try { - const verifyResponse = await fetch(`${apiBaseUrl}/api/google-one-tap/verify`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Origin: window.location.origin, - }, - body: JSON.stringify({ - idToken: response.credential, - }), - }); + const verifyResponse = await fetch( + `${apiBaseUrl}/api/google-one-tap/verify?idToken=${response.credential}`, + { method: 'GET' } + ); if (!verifyResponse.ok) { throw new Error(`Verification failed: ${verifyResponse.status}`); From ff5baeab14908c63d57ef98ee5181c356fadc42c Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Wed, 2 Jul 2025 20:24:34 +0800 Subject: [PATCH 04/11] chore: use otp landing page --- src/theme/Layout/GoogleOneTapInitializer.tsx | 18 ++++++++++-------- src/theme/Layout/hooks.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/theme/Layout/GoogleOneTapInitializer.tsx b/src/theme/Layout/GoogleOneTapInitializer.tsx index d3f706ffd74..33df9c64366 100644 --- a/src/theme/Layout/GoogleOneTapInitializer.tsx +++ b/src/theme/Layout/GoogleOneTapInitializer.tsx @@ -8,6 +8,7 @@ import type { GoogleOneTapCredentialResponse, GoogleOneTapVerifyResponse, } from './types'; +import { appendPath } from '@silverhand/essentials'; type GoogleOneTapInitializerProps = { readonly config: GoogleOneTapConfig; @@ -20,23 +21,24 @@ export default function GoogleOneTapInitializer({ debugLogger, siteConfig, }: GoogleOneTapInitializerProps): ReactNode { - const { baseUrl: apiBaseUrl, authUrl, redirectUri } = useApiBaseUrl(siteConfig); + const { baseUrl: apiBaseUrl, logtoAdminConsoleUrl } = useApiBaseUrl(siteConfig); + const verifyGoogleOneTap = useGoogleOneTapVerify(apiBaseUrl, debugLogger); // Function to manually build Logto sign-in URL const buildSignInUrl = useCallback( ({ oneTimeToken, email, isNewUser }: GoogleOneTapVerifyResponse) => { try { - const signInUrl = new URL(authUrl); + if (!logtoAdminConsoleUrl) { + throw new Error('Logto admin console URL is not set'); + } - // Standard OIDC parameters: client_id - signInUrl.searchParams.set('client_id', 'admin-console'); - signInUrl.searchParams.set('redirect_uri', redirectUri); - signInUrl.searchParams.set('first_screen', isNewUser ? 'register' : 'sign_in'); + const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'one-time-token-landing')); // Add one-time token parameters signInUrl.searchParams.set('one_time_token', oneTimeToken); - signInUrl.searchParams.set('login_hint', email); + signInUrl.searchParams.set('email', email); + signInUrl.searchParams.set('is_new_user', isNewUser ? 'true' : 'false'); return signInUrl.toString(); } catch (error) { @@ -44,7 +46,7 @@ export default function GoogleOneTapInitializer({ return null; } }, - [authUrl, redirectUri, debugLogger] + [logtoAdminConsoleUrl, debugLogger] ); const handleCredentialResponse = useCallback( diff --git a/src/theme/Layout/hooks.ts b/src/theme/Layout/hooks.ts index 15ae1886c2b..cbee34ad4a3 100644 --- a/src/theme/Layout/hooks.ts +++ b/src/theme/Layout/hooks.ts @@ -32,6 +32,7 @@ export function useApiBaseUrl(siteConfig: SiteConfig): { baseUrl: string; authUrl: string; redirectUri: string; + logtoAdminConsoleUrl?: string; } { return useMemo(() => { const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl; @@ -43,12 +44,18 @@ export function useApiBaseUrl(siteConfig: SiteConfig): { : defaultApiBaseProdUrl; const authUrl = `${baseUrl}/oidc/auth`; const redirectUri = `${typeof logtoApiBaseUrl === 'string' ? `${logtoApiBaseUrl}/${new URL(logtoApiBaseUrl).hostname === 'localhost' ? 'demo-app' : 'callback'}` : `${defaultApiBaseProdUrl}/callback`}`; + const logtoAdminConsoleUrl = siteConfig.customFields?.logtoAdminConsoleUrl; return { baseUrl, authUrl, redirectUri, + logtoAdminConsoleUrl, }; - }, [siteConfig.customFields?.logtoApiBaseUrl, siteConfig.customFields?.isDevFeatureEnabled]); + }, [ + siteConfig.customFields?.logtoApiBaseUrl, + siteConfig.customFields?.isDevFeatureEnabled, + siteConfig.customFields?.logtoAdminConsoleUrl, + ]); } export function useGoogleOneTapConfig( From 364385914fd235f7c13101c5b2ab38ff9b652e00 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 3 Jul 2025 14:59:31 +0800 Subject: [PATCH 05/11] chore: add frame-ancestor config --- static/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/_headers b/static/_headers index dda873bb9be..1f17da62f9c 100644 --- a/static/_headers +++ b/static/_headers @@ -14,4 +14,4 @@ # - connect-src: Allows network requests to Google's authentication endpoints # - style-src: Allows Google's CSS and inline styles # - font-src: Allows fonts from CDN, Google Fonts, and data URIs - Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://apis.google.com https://www.gstatic.com https://akasha.logto.io https://cdn.jsdelivr.net; img-src 'self' data: https: https://accounts.google.com https://lh3.googleusercontent.com; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://akasha.logto.io https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com/css2 https://accounts.google.com/gsi/style; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.googleapis.com/css2; object-src 'none'; base-uri 'self'; frame-src 'self' https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; + Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://apis.google.com https://www.gstatic.com https://akasha.logto.io https://cdn.jsdelivr.net; img-src 'self' data: https: https://accounts.google.com https://lh3.googleusercontent.com; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://akasha.logto.io https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com/css2 https://accounts.google.com/gsi/style; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.googleapis.com/css2; object-src 'none'; base-uri 'self'; frame-src 'self' https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; frame-ancestors 'self' https://docs.logto.io https://*.logto-docs.pages.dev; From 2d3d23ca55017cc68847fc04cc819230c8628ab8 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 3 Jul 2025 14:59:47 +0800 Subject: [PATCH 06/11] chore: pop up fallback mechanism --- src/theme/Layout/GoogleOneTapInitializer.tsx | 27 +++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/theme/Layout/GoogleOneTapInitializer.tsx b/src/theme/Layout/GoogleOneTapInitializer.tsx index 33df9c64366..b97265171b3 100644 --- a/src/theme/Layout/GoogleOneTapInitializer.tsx +++ b/src/theme/Layout/GoogleOneTapInitializer.tsx @@ -63,9 +63,30 @@ export default function GoogleOneTapInitializer({ const signInUrl = buildSignInUrl(verifyData); if (signInUrl) { - // Open sign-in URL in new tab - window.open(signInUrl, '_blank', 'noopener,noreferrer'); - debugLogger.log('Logto sign-in URL opened in new tab with one-time token', signInUrl); + try { + // Open sign-in URL in new tab + const newWindow = window.open(signInUrl, '_blank', 'noopener,noreferrer'); + + // Check if popup was blocked + if (!newWindow || newWindow.closed || typeof newWindow.closed === 'undefined') { + // Popup was blocked, provide fallback option + const fallback = window.confirm( + 'Popup was blocked by browser. Click OK to open the login page in the current window.' + ); + + if (fallback) { + window.location.href = signInUrl; + debugLogger.log('Redirecting to Logto sign-in URL in current window', signInUrl); + } + } else { + debugLogger.log('Logto sign-in URL opened in new tab with one-time token', signInUrl); + } + } catch (error) { + debugLogger.error('Failed to open popup, falling back to current window:', error); + // Fallback to current window navigation + window.location.href = signInUrl; + debugLogger.log('Redirecting to Logto sign-in URL in current window', signInUrl); + } } else { debugLogger.error('Failed to build sign-in URL'); } From b8f39c56b48a1f523ea6c5bfabb018bd35b72099 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 3 Jul 2025 15:46:13 +0800 Subject: [PATCH 07/11] chore: redirect in-place and try POST --- src/theme/Layout/GoogleOneTapInitializer.tsx | 29 +++----------------- src/theme/Layout/credential-verifier.ts | 11 +++++--- 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/src/theme/Layout/GoogleOneTapInitializer.tsx b/src/theme/Layout/GoogleOneTapInitializer.tsx index b97265171b3..783841aedb0 100644 --- a/src/theme/Layout/GoogleOneTapInitializer.tsx +++ b/src/theme/Layout/GoogleOneTapInitializer.tsx @@ -33,7 +33,7 @@ export default function GoogleOneTapInitializer({ throw new Error('Logto admin console URL is not set'); } - const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'one-time-token-landing')); + const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'one-time-token')); // Add one-time token parameters signInUrl.searchParams.set('one_time_token', oneTimeToken); @@ -63,30 +63,9 @@ export default function GoogleOneTapInitializer({ const signInUrl = buildSignInUrl(verifyData); if (signInUrl) { - try { - // Open sign-in URL in new tab - const newWindow = window.open(signInUrl, '_blank', 'noopener,noreferrer'); - - // Check if popup was blocked - if (!newWindow || newWindow.closed || typeof newWindow.closed === 'undefined') { - // Popup was blocked, provide fallback option - const fallback = window.confirm( - 'Popup was blocked by browser. Click OK to open the login page in the current window.' - ); - - if (fallback) { - window.location.href = signInUrl; - debugLogger.log('Redirecting to Logto sign-in URL in current window', signInUrl); - } - } else { - debugLogger.log('Logto sign-in URL opened in new tab with one-time token', signInUrl); - } - } catch (error) { - debugLogger.error('Failed to open popup, falling back to current window:', error); - // Fallback to current window navigation - window.location.href = signInUrl; - debugLogger.log('Redirecting to Logto sign-in URL in current window', signInUrl); - } + // Directly navigate to sign-in URL in current window + window.location.href = signInUrl; + debugLogger.log('Redirecting to Logto sign-in URL', signInUrl); } else { debugLogger.error('Failed to build sign-in URL'); } diff --git a/src/theme/Layout/credential-verifier.ts b/src/theme/Layout/credential-verifier.ts index 3b3a35fc1cf..e7b5c69c6d2 100644 --- a/src/theme/Layout/credential-verifier.ts +++ b/src/theme/Layout/credential-verifier.ts @@ -13,10 +13,13 @@ export async function verifyGoogleOneTapCredential( debugLogger.log('Google One Tap credential response received:', response); try { - const verifyResponse = await fetch( - `${apiBaseUrl}/api/google-one-tap/verify?idToken=${response.credential}`, - { method: 'GET' } - ); + const verifyResponse = await fetch(`${apiBaseUrl}/api/google-one-tap/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ idToken: response.credential }), + }); if (!verifyResponse.ok) { throw new Error(`Verification failed: ${verifyResponse.status}`); From 5c1e5669076829a95da629fc839a149c267738fe Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 7 Jul 2025 12:58:06 +0800 Subject: [PATCH 08/11] chore: test without CSP headers for CF --- static/_headers | 9 --------- 1 file changed, 9 deletions(-) diff --git a/static/_headers b/static/_headers index 1f17da62f9c..f6a5d5ee1a3 100644 --- a/static/_headers +++ b/static/_headers @@ -6,12 +6,3 @@ # Cross-Origin policies for Google One Tap Cross-Origin-Opener-Policy: same-origin-allow-popups Referrer-Policy: strict-origin-when-cross-origin - - # Content Security Policy for Google One Tap integration - # Essential CSP directives to prevent "Can't continue with google.com" errors: - # - script-src: Allows Google Identity Services scripts - # - img-src: Allows Google profile pictures and branding - # - connect-src: Allows network requests to Google's authentication endpoints - # - style-src: Allows Google's CSS and inline styles - # - font-src: Allows fonts from CDN, Google Fonts, and data URIs - Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://apis.google.com https://www.gstatic.com https://akasha.logto.io https://cdn.jsdelivr.net; img-src 'self' data: https: https://accounts.google.com https://lh3.googleusercontent.com; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://akasha.logto.io https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com/css2 https://accounts.google.com/gsi/style; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.googleapis.com/css2; object-src 'none'; base-uri 'self'; frame-src 'self' https://cloud.logto.dev https://auth.logto.dev https://cloud.logto.io https://auth.logto.io; frame-ancestors 'self' https://docs.logto.io https://*.logto-docs.pages.dev; From 910ed6e1c81dcca022eef5f6bb552f9d9307de74 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Wed, 9 Jul 2025 18:38:46 +0800 Subject: [PATCH 09/11] chore: test experience google credential --- src/theme/Layout/GoogleOneTapInitializer.tsx | 45 ++++++++------------ src/theme/Layout/auth-status.ts | 1 + src/theme/Layout/types.ts | 4 +- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/theme/Layout/GoogleOneTapInitializer.tsx b/src/theme/Layout/GoogleOneTapInitializer.tsx index 783841aedb0..c64634bcd21 100644 --- a/src/theme/Layout/GoogleOneTapInitializer.tsx +++ b/src/theme/Layout/GoogleOneTapInitializer.tsx @@ -21,24 +21,19 @@ export default function GoogleOneTapInitializer({ debugLogger, siteConfig, }: GoogleOneTapInitializerProps): ReactNode { - const { baseUrl: apiBaseUrl, logtoAdminConsoleUrl } = useApiBaseUrl(siteConfig); - - const verifyGoogleOneTap = useGoogleOneTapVerify(apiBaseUrl, debugLogger); + const { logtoAdminConsoleUrl } = useApiBaseUrl(siteConfig); // Function to manually build Logto sign-in URL const buildSignInUrl = useCallback( - ({ oneTimeToken, email, isNewUser }: GoogleOneTapVerifyResponse) => { + ({ credential }: GoogleOneTapVerifyResponse) => { try { if (!logtoAdminConsoleUrl) { throw new Error('Logto admin console URL is not set'); } - const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'one-time-token')); + const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'google-one-tap')); - // Add one-time token parameters - signInUrl.searchParams.set('one_time_token', oneTimeToken); - signInUrl.searchParams.set('email', email); - signInUrl.searchParams.set('is_new_user', isNewUser ? 'true' : 'false'); + signInUrl.searchParams.set('google_one_tap_credential', credential); return signInUrl.toString(); } catch (error) { @@ -53,28 +48,22 @@ export default function GoogleOneTapInitializer({ async (response: GoogleOneTapCredentialResponse) => { debugLogger.log('handleCredentialResponse received response:', response); - const verifyData = await verifyGoogleOneTap(response); - - if (verifyData) { - debugLogger.log('Verification completed:', verifyData); - - try { - // Build Logto sign-in URL with one-time token - const signInUrl = buildSignInUrl(verifyData); - - if (signInUrl) { - // Directly navigate to sign-in URL in current window - window.location.href = signInUrl; - debugLogger.log('Redirecting to Logto sign-in URL', signInUrl); - } else { - debugLogger.error('Failed to build sign-in URL'); - } - } catch (error) { - debugLogger.error('Failed to open sign-in URL:', error); + try { + // Build Logto sign-in URL with one-time token + const signInUrl = buildSignInUrl(response); + + if (signInUrl) { + // Directly navigate to sign-in URL in current window + window.location.href = signInUrl; + debugLogger.log('Redirecting to Logto sign-in URL', signInUrl); + } else { + debugLogger.error('Failed to build sign-in URL'); } + } catch (error) { + debugLogger.error('Failed to open sign-in URL:', error); } }, - [verifyGoogleOneTap, debugLogger, buildSignInUrl] + [debugLogger, buildSignInUrl] ); useEffect(() => { diff --git a/src/theme/Layout/auth-status.ts b/src/theme/Layout/auth-status.ts index b78af06bec0..d61dea88326 100644 --- a/src/theme/Layout/auth-status.ts +++ b/src/theme/Layout/auth-status.ts @@ -59,6 +59,7 @@ export function createAuthStatusChecker({ const iframe = document.createElement('iframe'); iframe.src = iframeSrc; + iframe.sandbox = 'allow-scripts allow-same-origin'; if (isIframeVisible) { // Temporarily show iframe for debugging diff --git a/src/theme/Layout/types.ts b/src/theme/Layout/types.ts index fa70c1037f6..eea6c06c11c 100644 --- a/src/theme/Layout/types.ts +++ b/src/theme/Layout/types.ts @@ -32,9 +32,7 @@ export type GoogleOneTapCredentialResponse = { }; export type GoogleOneTapVerifyResponse = { - oneTimeToken: string; - isNewUser: boolean; - email: string; + credential: string; }; export type AuthStatusGlobal = { From 8bc5b23138a2539d900e6ac90bea54ddb25d3f99 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 15 Jul 2025 23:46:05 +0800 Subject: [PATCH 10/11] chore: add debug log and check/grant storage access --- src/theme/Layout/auth-status.ts | 32 +++- src/theme/Layout/hooks.ts | 33 ++-- src/theme/Layout/storage-access.ts | 272 +++++++++++++++++++++++++++++ src/theme/Layout/types.ts | 19 ++ 4 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 src/theme/Layout/storage-access.ts diff --git a/src/theme/Layout/auth-status.ts b/src/theme/Layout/auth-status.ts index d61dea88326..0aa6ecddd94 100644 --- a/src/theme/Layout/auth-status.ts +++ b/src/theme/Layout/auth-status.ts @@ -6,6 +6,7 @@ import { debugIframeTimeoutDelay, } from './constants'; import type { DebugLogger } from './debug-logger'; +import { createStorageAccessChecker } from './storage-access'; import { AuthMessageType, type AuthStatusRequest, type AuthStatusResponse } from './types'; export type AuthStatusCheckerOptions = { @@ -36,6 +37,12 @@ export function createAuthStatusChecker({ const authStatusCheckerHost = typeof logtoAdminConsoleUrl === 'string' ? new URL(logtoAdminConsoleUrl).origin : undefined; + const { checkStorageAccess, requestStorageAccess } = createStorageAccessChecker({ + logtoAdminConsoleUrl, + enableAuthStatusCheck, + debugLogger, + }); + /** * Function to check admin token status via cross-domain iframe communication * @@ -46,6 +53,27 @@ export function createAuthStatusChecker({ * @throws Error if auth status checker is not configured or request fails */ const checkAdminTokenStatus = async (): Promise => { + try { + debugLogger.log('Checking storage access before admin token check'); + + const hasStorageAccess = await checkStorageAccess(); + debugLogger.log('Storage access check result:', hasStorageAccess); + + if (!hasStorageAccess) { + debugLogger.log('Storage access not available, requesting access'); + const storageAccessGranted = await requestStorageAccess(); + debugLogger.log('Storage access request result:', storageAccessGranted); + + if (!storageAccessGranted) { + throw new Error('Storage access required but not granted'); + } + } + + debugLogger.log('Storage access confirmed, proceeding with admin token check'); + } catch (error) { + debugLogger.warn('Storage access check/request failed:', error); + } + return new Promise((resolve, reject) => { if (typeof document === 'undefined') { reject(new Error('Document not available (SSR environment)')); @@ -59,7 +87,7 @@ export function createAuthStatusChecker({ const iframe = document.createElement('iframe'); iframe.src = iframeSrc; - iframe.sandbox = 'allow-scripts allow-same-origin'; + iframe.sandbox = 'allow-scripts allow-same-origin allow-storage-access-by-user-activation'; if (isIframeVisible) { // Temporarily show iframe for debugging @@ -233,6 +261,8 @@ export function createAuthStatusChecker({ return { checkAdminTokenStatus, + checkStorageAccess, + requestStorageAccess, authStatusCheckerHost, iframeSrc, }; diff --git a/src/theme/Layout/hooks.ts b/src/theme/Layout/hooks.ts index cbee34ad4a3..89fc99f826c 100644 --- a/src/theme/Layout/hooks.ts +++ b/src/theme/Layout/hooks.ts @@ -105,6 +105,8 @@ export type AuthStatusResult = { authStatus?: boolean; authCheckError?: string; checkAdminTokenStatus: () => Promise; + checkStorageAccess: () => Promise; + requestStorageAccess: () => Promise; }; export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger): AuthStatusResult { @@ -117,18 +119,19 @@ export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger): const isDebugMode = Boolean(siteConfig.customFields?.isDebuggingEnabled); const isIframeVisible = Boolean(siteConfig.customFields?.isIframeVisible); - const { checkAdminTokenStatus, authStatusCheckerHost } = useMemo( - () => - createAuthStatusChecker({ - logtoAdminConsoleUrl: - typeof logtoAdminConsoleUrl === 'string' ? logtoAdminConsoleUrl : undefined, - enableAuthStatusCheck: Boolean(enableAuthStatusCheck), - isDebugMode, - isIframeVisible, - debugLogger, - }), - [logtoAdminConsoleUrl, enableAuthStatusCheck, isDebugMode, isIframeVisible, debugLogger] - ); + const { checkAdminTokenStatus, checkStorageAccess, requestStorageAccess, authStatusCheckerHost } = + useMemo( + () => + createAuthStatusChecker({ + logtoAdminConsoleUrl: + typeof logtoAdminConsoleUrl === 'string' ? logtoAdminConsoleUrl : undefined, + enableAuthStatusCheck: Boolean(enableAuthStatusCheck), + isDebugMode, + isIframeVisible, + debugLogger, + }), + [logtoAdminConsoleUrl, enableAuthStatusCheck, isDebugMode, isIframeVisible, debugLogger] + ); const performAuthCheckWithRetry = useCallback( async (retryCount = 0): Promise => { @@ -221,9 +224,11 @@ export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger): authStatus, authCheckError, checkAdminTokenStatus, + checkStorageAccess, + requestStorageAccess, }; } - }, [authStatus, authCheckError, checkAdminTokenStatus]); + }, [authStatus, authCheckError, checkAdminTokenStatus, checkStorageAccess, requestStorageAccess]); // Debug logging useEffect(() => { @@ -255,6 +260,8 @@ export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger): authStatus, authCheckError, checkAdminTokenStatus, + checkStorageAccess, + requestStorageAccess, }; } /* eslint-enable @silverhand/fp/no-mutation */ diff --git a/src/theme/Layout/storage-access.ts b/src/theme/Layout/storage-access.ts new file mode 100644 index 00000000000..e5a682b9f84 --- /dev/null +++ b/src/theme/Layout/storage-access.ts @@ -0,0 +1,272 @@ +/* eslint-disable @silverhand/fp/no-mutation */ +import { iframeLoadDelay, requestTimeout } from './constants'; +import type { DebugLogger } from './debug-logger'; +import { AuthMessageType, type StorageAccessRequest, type StorageAccessResponse } from './types'; + +export type StorageAccessCheckerOptions = { + logtoAdminConsoleUrl?: string; + enableAuthStatusCheck?: boolean; + debugLogger: DebugLogger; +}; + +export function createStorageAccessChecker({ + logtoAdminConsoleUrl, + enableAuthStatusCheck, + debugLogger, +}: StorageAccessCheckerOptions) { + const iframeSrc = + typeof logtoAdminConsoleUrl === 'string' ? `${logtoAdminConsoleUrl}/auth-status` : undefined; + + const authStatusCheckerHost = + typeof logtoAdminConsoleUrl === 'string' ? new URL(logtoAdminConsoleUrl).origin : undefined; + + const checkStorageAccess = async (): Promise => { + return new Promise((resolve, reject) => { + if (typeof document === 'undefined') { + reject(new Error('Document not available (SSR environment)')); + return; + } + + if (!logtoAdminConsoleUrl || !enableAuthStatusCheck || !iframeSrc) { + reject(new Error('Auth status checker not configured')); + return; + } + + const iframe = document.createElement('iframe'); + iframe.src = iframeSrc; + iframe.sandbox = 'allow-scripts allow-same-origin allow-storage-access-by-user-activation'; + iframe.style.display = 'none'; + document.body.append(iframe); + + const requestId = Math.random().toString(36).slice(7); + // eslint-disable-next-line @silverhand/fp/no-let, prefer-const + let timeoutId: NodeJS.Timeout; + // eslint-disable-next-line @silverhand/fp/no-let + let messageHandlerAdded = false; + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== authStatusCheckerHost) { + debugLogger.warn( + 'Storage access check: Origin mismatch:', + event.origin, + 'vs', + authStatusCheckerHost + ); + return; + } + + const { data } = event; + if (typeof data !== 'object' || data.requestId !== requestId) { + return; + } + + debugLogger.log('Storage access check response:', data); + + clearTimeout(timeoutId); + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + messageHandlerAdded = false; + } + + if (document.body.contains(iframe)) { + iframe.remove(); + } + + switch (data.type) { + case AuthMessageType.StorageAccessStatus: { + resolve(Boolean(data.hasStorageAccess)); + break; + } + case AuthMessageType.StorageAccessError: { + reject(new Error(String(data.error || 'Storage access check failed'))); + break; + } + } + }; + + window.addEventListener('message', handleMessage); + messageHandlerAdded = true; + + iframe.addEventListener('load', () => { + setTimeout(() => { + try { + const message: StorageAccessRequest = { + type: AuthMessageType.CheckStorageAccess, + requestId, + }; + + debugLogger.log('Sending storage access check message:', message); + iframe.contentWindow?.postMessage(message, authStatusCheckerHost ?? ''); + } catch (error) { + clearTimeout(timeoutId); + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + messageHandlerAdded = false; + } + if (document.body.contains(iframe)) { + iframe.remove(); + } + reject( + new Error( + `Failed to send storage access check message: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ) + ); + } + }, iframeLoadDelay); + }); + + // eslint-disable-next-line unicorn/prefer-add-event-listener + iframe.onerror = () => { + clearTimeout(timeoutId); + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + } + if (document.body.contains(iframe)) { + iframe.remove(); + } + reject(new Error('Storage access check: iframe failed to load')); + }; + + timeoutId = setTimeout(() => { + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + } + if (document.body.contains(iframe)) { + iframe.remove(); + } + reject(new Error('Storage access check: Request timeout')); + }, requestTimeout); + }); + }; + + const requestStorageAccess = async (): Promise => { + return new Promise((resolve, reject) => { + if (typeof document === 'undefined') { + reject(new Error('Document not available (SSR environment)')); + return; + } + + if (!logtoAdminConsoleUrl || !enableAuthStatusCheck || !iframeSrc) { + reject(new Error('Auth status checker not configured')); + return; + } + + const iframe = document.createElement('iframe'); + iframe.src = iframeSrc; + iframe.sandbox = 'allow-scripts allow-same-origin allow-storage-access-by-user-activation'; + iframe.style.display = 'none'; + document.body.append(iframe); + + const requestId = Math.random().toString(36).slice(7); + // eslint-disable-next-line @silverhand/fp/no-let, prefer-const + let timeoutId: NodeJS.Timeout; + // eslint-disable-next-line @silverhand/fp/no-let + let messageHandlerAdded = false; + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== authStatusCheckerHost) { + debugLogger.warn( + 'Storage access request: Origin mismatch:', + event.origin, + 'vs', + authStatusCheckerHost + ); + return; + } + + const { data } = event; + if (typeof data !== 'object' || data.requestId !== requestId) { + return; + } + + debugLogger.log('Storage access request response:', data); + + clearTimeout(timeoutId); + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + messageHandlerAdded = false; + } + + if (document.body.contains(iframe)) { + iframe.remove(); + } + + switch (data.type) { + case AuthMessageType.StorageAccessStatus: { + resolve(Boolean(data.storageAccessGranted)); + break; + } + case AuthMessageType.StorageAccessError: { + reject(new Error(String(data.error || 'Storage access request failed'))); + break; + } + } + }; + + window.addEventListener('message', handleMessage); + messageHandlerAdded = true; + + iframe.addEventListener('load', () => { + setTimeout(() => { + try { + const message: StorageAccessRequest = { + type: AuthMessageType.RequestStorageAccess, + requestId, + }; + + debugLogger.log('Sending storage access request message:', message); + iframe.contentWindow?.postMessage(message, authStatusCheckerHost ?? ''); + } catch (error) { + clearTimeout(timeoutId); + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + messageHandlerAdded = false; + } + if (document.body.contains(iframe)) { + iframe.remove(); + } + reject( + new Error( + `Failed to send storage access request message: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ) + ); + } + }, iframeLoadDelay); + }); + + // eslint-disable-next-line unicorn/prefer-add-event-listener + iframe.onerror = () => { + clearTimeout(timeoutId); + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + } + if (document.body.contains(iframe)) { + iframe.remove(); + } + reject(new Error('Storage access request: iframe failed to load')); + }; + + timeoutId = setTimeout(() => { + if (messageHandlerAdded) { + window.removeEventListener('message', handleMessage); + } + if (document.body.contains(iframe)) { + iframe.remove(); + } + reject(new Error('Storage access request: Request timeout')); + }, requestTimeout); + }); + }; + + return { + checkStorageAccess, + requestStorageAccess, + authStatusCheckerHost, + iframeSrc, + }; +} +/* eslint-enable @silverhand/fp/no-mutation */ diff --git a/src/theme/Layout/types.ts b/src/theme/Layout/types.ts index eea6c06c11c..6661b8b8fbc 100644 --- a/src/theme/Layout/types.ts +++ b/src/theme/Layout/types.ts @@ -2,6 +2,10 @@ export const enum AuthMessageType { CheckAdminToken = 'CheckAdminToken', AdminTokenStatus = 'AdminTokenStatus', AdminTokenError = 'AdminTokenError', + CheckStorageAccess = 'CheckStorageAccess', + RequestStorageAccess = 'RequestStorageAccess', + StorageAccessStatus = 'StorageAccessStatus', + StorageAccessError = 'StorageAccessError', } export type AuthStatusRequest = { @@ -9,6 +13,11 @@ export type AuthStatusRequest = { requestId: string; }; +export type StorageAccessRequest = { + type: AuthMessageType.CheckStorageAccess | AuthMessageType.RequestStorageAccess; + requestId: string; +}; + export type AuthStatusResponse = { type: AuthMessageType.AdminTokenStatus | AuthMessageType.AdminTokenError; requestId: string; @@ -16,6 +25,14 @@ export type AuthStatusResponse = { error?: string; }; +export type StorageAccessResponse = { + type: AuthMessageType.StorageAccessStatus | AuthMessageType.StorageAccessError; + requestId: string; + hasStorageAccess?: boolean; + storageAccessGranted?: boolean; + error?: string; +}; + export type SiteConfig = { customFields?: { isDebuggingEnabled?: boolean; @@ -39,6 +56,8 @@ export type AuthStatusGlobal = { authStatus: boolean | undefined; authCheckError: string | undefined; checkAdminTokenStatus: () => Promise; + checkStorageAccess: () => Promise; + requestStorageAccess: () => Promise; }; // Extend Window interface for TypeScript From 0970bae66d9b796340eb9883823be3f714c57c26 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Fri, 18 Jul 2025 13:22:39 +0800 Subject: [PATCH 11/11] chore: update console landing page --- src/theme/Layout/GoogleOneTapInitializer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/theme/Layout/GoogleOneTapInitializer.tsx b/src/theme/Layout/GoogleOneTapInitializer.tsx index c64634bcd21..fd414e7be89 100644 --- a/src/theme/Layout/GoogleOneTapInitializer.tsx +++ b/src/theme/Layout/GoogleOneTapInitializer.tsx @@ -31,9 +31,9 @@ export default function GoogleOneTapInitializer({ throw new Error('Logto admin console URL is not set'); } - const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'google-one-tap')); + const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'external-google-one-tap')); - signInUrl.searchParams.set('google_one_tap_credential', credential); + signInUrl.searchParams.set('credential', credential); return signInUrl.toString(); } catch (error) {