Skip to content

feat: add GOT callback handler #1166

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: yemq-add-auth-status-check
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 55 additions & 14 deletions src/theme/Layout/GoogleOneTapInitializer.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,70 @@
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';
import { appendPath } from '@silverhand/essentials';

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 { logtoAdminConsoleUrl } = useApiBaseUrl(siteConfig);

// Function to manually build Logto sign-in URL
const buildSignInUrl = useCallback(
({ credential }: GoogleOneTapVerifyResponse) => {
try {
if (!logtoAdminConsoleUrl) {
throw new Error('Logto admin console URL is not set');
}

const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'google-one-tap'));

signInUrl.searchParams.set('google_one_tap_credential', credential);

return signInUrl.toString();
} catch (error) {
debugLogger.error('Failed to build sign-in URL:', error);
return null;
}
},
[logtoAdminConsoleUrl, debugLogger]
);

const handleCredentialResponse = useCallback(
async (response: GoogleOneTapCredentialResponse) => {
debugLogger.log('handleCredentialResponse received response:', response);

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);
}
},
[debugLogger, buildSignInUrl]
);

useEffect(() => {
if (config.oneTap?.isEnabled && window.google?.accounts.id) {
Expand All @@ -34,7 +75,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,
Expand Down
31 changes: 31 additions & 0 deletions src/theme/Layout/auth-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
*
Expand All @@ -46,6 +53,27 @@ export function createAuthStatusChecker({
* @throws Error if auth status checker is not configured or request fails
*/
const checkAdminTokenStatus = async (): Promise<boolean> => {
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)'));
Expand All @@ -59,6 +87,7 @@ export function createAuthStatusChecker({

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

if (isIframeVisible) {
// Temporarily show iframe for debugging
Expand Down Expand Up @@ -232,6 +261,8 @@ export function createAuthStatusChecker({

return {
checkAdminTokenStatus,
checkStorageAccess,
requestStorageAccess,
authStatusCheckerHost,
iframeSrc,
};
Expand Down
6 changes: 1 addition & 5 deletions src/theme/Layout/config-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
37 changes: 37 additions & 0 deletions src/theme/Layout/credential-verifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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<GoogleOneTapVerifyResponse | undefined> {
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',
},
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;
}
}
89 changes: 66 additions & 23 deletions src/theme/Layout/hooks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,31 +13,55 @@ 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 { SiteConfig } from './types';
import type {
SiteConfig,
GoogleOneTapCredentialResponse,
GoogleOneTapVerifyResponse,
} from './types';

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

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

export function useApiBaseUrl(siteConfig: SiteConfig): string {
export function useApiBaseUrl(siteConfig: SiteConfig): {
baseUrl: string;
authUrl: string;
redirectUri: string;
logtoAdminConsoleUrl?: string;
} {
return useMemo(() => {
const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl;
return typeof logtoApiBaseUrl === 'string'
? logtoApiBaseUrl
: siteConfig.customFields?.isDevFeatureEnabled
? defaultApiBaseDevUrl
: defaultApiBaseProdUrl;
}, [siteConfig.customFields?.logtoApiBaseUrl, siteConfig.customFields?.isDevFeatureEnabled]);
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`}`;
const logtoAdminConsoleUrl = siteConfig.customFields?.logtoAdminConsoleUrl;
return {
baseUrl,
authUrl,
redirectUri,
logtoAdminConsoleUrl,
};
}, [
siteConfig.customFields?.logtoApiBaseUrl,
siteConfig.customFields?.isDevFeatureEnabled,
siteConfig.customFields?.logtoAdminConsoleUrl,
]);
}

export function useGoogleOneTapConfig(
apiBaseUrl: string,
debugLogger: DebugLogger
): GoogleOneTapConfig | undefined {
): Optional<GoogleOneTapConfig> {
const [config, setConfig] = useState<GoogleOneTapConfig>();

useEffect(() => {
Expand Down Expand Up @@ -65,10 +89,24 @@ export function useGoogleOneTapConfig(
return config;
}

export function useGoogleOneTapVerify(
apiBaseUrl: string,
debugLogger: DebugLogger
): (response: GoogleOneTapCredentialResponse) => Promise<Optional<GoogleOneTapVerifyResponse>> {
return useCallback(
async (response: GoogleOneTapCredentialResponse) => {
return verifyGoogleOneTapCredential({ apiBaseUrl, debugLogger }, response);
},
[apiBaseUrl, debugLogger]
);
}

export type AuthStatusResult = {
authStatus?: boolean;
authCheckError?: string;
checkAdminTokenStatus: () => Promise<boolean>;
checkStorageAccess: () => Promise<boolean>;
requestStorageAccess: () => Promise<boolean>;
};

export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger): AuthStatusResult {
Expand All @@ -81,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<void> => {
Expand Down Expand Up @@ -185,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(() => {
Expand Down Expand Up @@ -219,6 +260,8 @@ export function useAuthStatus(siteConfig: SiteConfig, debugLogger: DebugLogger):
authStatus,
authCheckError,
checkAdminTokenStatus,
checkStorageAccess,
requestStorageAccess,
};
}
/* eslint-enable @silverhand/fp/no-mutation */
10 changes: 8 additions & 2 deletions src/theme/Layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -28,7 +28,13 @@ export default function LayoutWrapper(props: Props): ReactNode {
<Layout {...props} />
{authStatus === false && config?.oneTap?.isEnabled && (
<BrowserOnly fallback={<div>Loading Google Sign-In...</div>}>
{() => <GoogleOneTapInitializer config={config} debugLogger={debugLogger} />}
{() => (
<GoogleOneTapInitializer
config={config}
debugLogger={debugLogger}
siteConfig={siteConfig}
/>
)}
</BrowserOnly>
)}
</>
Expand Down
Loading
Loading