Skip to content

Commit f22a447

Browse files
authored
feat: add Google One Tap response handler (#1126)
* feat: check auth status on cloud console * refactor: refactor code * fix: fix page * chore: fix content not render bug * chore: resume least changes * refactor: apply try-retry instead of polling * refactor: use google API instead of HTML component * revert: restore pnpm-lock.yaml to base branch version * refactor: apply browser only to GOT and add referrer policy header * refactor: apply thorough CSP * 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 * refactor: remove unnecessary code * feat: only trigger GOT once * chore: remove unused code * refactor: refactor code
1 parent 2f1ff0e commit f22a447

File tree

8 files changed

+278
-93
lines changed

8 files changed

+278
-93
lines changed

docusaurus.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// eslint-disable-next-line import/no-unassigned-import
22
import 'dotenv/config';
3+
34
import type { Config } from '@docusaurus/types';
45
import { yes } from '@silverhand/essentials';
56

@@ -54,6 +55,9 @@ const config: Config = {
5455
inkeepApiKey: process.env.INKEEP_API_KEY,
5556
logtoApiBaseUrl: process.env.LOGTO_API_BASE_URL,
5657
isDevFeatureEnabled: yes(process.env.IS_DEV_FEATURE_ENABLED),
58+
isDebuggingEnabled: yes(process.env.IS_DEBUGGING_ENABLED),
59+
logtoAdminConsoleUrl: process.env.LOGTO_ADMIN_CONSOLE_URL,
60+
googleOneTapConfig: process.env.GOOGLE_ONE_TAP_CONFIG,
5761
},
5862

5963
staticDirectories: ['static', 'static-localized/' + currentLocale],
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { type ReactNode, useCallback, useEffect, useState } from 'react';
2+
3+
import type { GoogleOneTapCredentialResponse } from './types';
4+
import { appendPath, yes } from '@silverhand/essentials';
5+
import { isGoogleOneTapTriggeredKey } from './constants';
6+
import { useApiBaseUrl, useDebugLogger, useGoogleOneTapConfig } from './hooks';
7+
8+
export default function GoogleOneTapInitializer(): ReactNode {
9+
const [isGoogleOneTapTriggered, setIsGoogleOneTapTriggered] = useState(false);
10+
const { logtoAdminConsoleUrl } = useApiBaseUrl();
11+
const { config } = useGoogleOneTapConfig();
12+
const { debugLogger } = useDebugLogger();
13+
14+
useEffect(() => {
15+
const isTriggered = yes(localStorage.getItem(isGoogleOneTapTriggeredKey));
16+
setIsGoogleOneTapTriggered(isTriggered);
17+
}, []);
18+
19+
// Function to manually build Logto sign-in URL
20+
const buildSignInUrl = useCallback(
21+
({ credential }: GoogleOneTapCredentialResponse) => {
22+
try {
23+
if (!logtoAdminConsoleUrl) {
24+
throw new Error('Logto admin console URL is not set');
25+
}
26+
27+
const signInUrl = new URL(appendPath(new URL(logtoAdminConsoleUrl), 'external-google-one-tap'));
28+
29+
signInUrl.searchParams.set('credential', credential);
30+
31+
return signInUrl.toString();
32+
} catch (error) {
33+
debugLogger.error('Failed to build sign-in URL:', error);
34+
return null;
35+
}
36+
},
37+
[logtoAdminConsoleUrl, debugLogger]
38+
);
39+
40+
const handleCredentialResponse = useCallback(
41+
async (response: GoogleOneTapCredentialResponse) => {
42+
debugLogger.log('handleCredentialResponse received response:', response);
43+
44+
// Build Logto sign-in URL with credential
45+
const signInUrl = buildSignInUrl(response);
46+
47+
if (signInUrl) {
48+
localStorage.setItem(isGoogleOneTapTriggeredKey, '1');
49+
// Directly navigate to sign-in URL in current window
50+
window.location.href = signInUrl;
51+
debugLogger.log('Redirecting to Logto sign-in URL', signInUrl);
52+
}
53+
},
54+
[debugLogger, buildSignInUrl]
55+
);
56+
57+
useEffect(() => {
58+
if (!isGoogleOneTapTriggered && logtoAdminConsoleUrl && config && config.oneTap?.isEnabled && window.google?.accounts.id) {
59+
debugLogger.log('Initializing Google One Tap');
60+
61+
try {
62+
// Initialize Google One Tap
63+
window.google.accounts.id.initialize({
64+
client_id: config.clientId,
65+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
66+
callback: handleCredentialResponse,
67+
auto_select: config.oneTap.autoSelect,
68+
cancel_on_tap_outside: config.oneTap.closeOnTapOutside,
69+
itp_support: config.oneTap.itpSupport,
70+
});
71+
72+
// Show One Tap prompt
73+
window.google.accounts.id.prompt();
74+
} catch (error) {
75+
console.error('Error initializing Google One Tap:', error);
76+
}
77+
}
78+
}, [config, debugLogger, logtoAdminConsoleUrl]);
79+
80+
return null;
81+
}

src/theme/Layout/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const isGoogleOneTapTriggeredKey = '_logto_is_google_one_tap_triggered';
2+
3+
export const defaultApiBaseProdUrl = 'https://auth.logto.io';
4+
export const defaultApiBaseDevUrl = 'https://auth.logto.dev';

src/theme/Layout/global.d.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
declare global {
2+
interface Window {
3+
handleCredentialResponse?: (response: GoogleCredentialResponse) => void;
4+
google?: {
5+
accounts: {
6+
id: {
7+
initialize: (config: GoogleOneTapInitConfig) => void;
8+
prompt: () => void;
9+
};
10+
};
11+
};
12+
}
13+
}
14+
15+
type GoogleCredentialResponse = {
16+
credential: string;
17+
};
18+
19+
type GoogleOneTapInitConfig = {
20+
client_id: string;
21+
callback: (response: GoogleCredentialResponse) => void;
22+
auto_select?: boolean;
23+
cancel_on_tap_outside?: boolean;
24+
itp_support?: boolean;
25+
};
26+
27+
export {};

src/theme/Layout/google-one-tap.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from 'zod';
2+
3+
export const oneTapSchema = z
4+
.object({
5+
isEnabled: z.boolean().optional(),
6+
autoSelect: z.boolean().optional(),
7+
closeOnTapOutside: z.boolean().optional(),
8+
itpSupport: z.boolean().optional(),
9+
})
10+
.optional();
11+
12+
export const googleOneTapConfigSchema = z.object({
13+
clientId: z.string(),
14+
oneTap: oneTapSchema,
15+
});
16+
17+
export type GoogleOneTapConfig = z.infer<typeof googleOneTapConfigSchema>;

src/theme/Layout/hooks.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
2+
import { trySafe, type Optional } from '@silverhand/essentials';
3+
import { useEffect, useMemo, useState } from 'react';
4+
5+
import { defaultApiBaseProdUrl, defaultApiBaseDevUrl } from './constants';
6+
import { type GoogleOneTapConfig, googleOneTapConfigSchema } from './google-one-tap';
7+
import { type RawSiteConfig, rawSiteConfigSchema } from './types';
8+
9+
type DebugLogger = {
10+
log: (...args: unknown[]) => void;
11+
warn: (...args: unknown[]) => void;
12+
error: (...args: unknown[]) => void;
13+
};
14+
15+
const createDebugLogger = (isDebugMode: boolean): DebugLogger => {
16+
return {
17+
log: (...args: unknown[]) => {
18+
if (isDebugMode) {
19+
console.log(...args);
20+
}
21+
},
22+
warn: (...args: unknown[]) => {
23+
if (isDebugMode) {
24+
console.warn(...args);
25+
}
26+
},
27+
error: (...args: unknown[]) => {
28+
if (isDebugMode) {
29+
console.error(...args);
30+
}
31+
},
32+
};
33+
};
34+
35+
const useSiteConfig = (): { siteConfig: RawSiteConfig } => {
36+
const { siteConfig: rawSiteConfig } = useDocusaurusContext();
37+
const parsedRawSiteConfig = rawSiteConfigSchema.safeParse(rawSiteConfig);
38+
if (!parsedRawSiteConfig.success) {
39+
throw new Error('Invalid site config');
40+
}
41+
return { siteConfig: parsedRawSiteConfig.data };
42+
};
43+
44+
export function useDebugLogger(): { debugLogger: DebugLogger } {
45+
const {
46+
siteConfig: { customFields },
47+
} = useSiteConfig();
48+
const isDebugMode = Boolean(customFields?.isDebuggingEnabled);
49+
50+
return { debugLogger: useMemo(() => createDebugLogger(isDebugMode), [isDebugMode]) };
51+
}
52+
53+
export function useApiBaseUrl(): {
54+
baseUrl: string;
55+
logtoAdminConsoleUrl?: string;
56+
} {
57+
const {
58+
siteConfig: { customFields },
59+
} = useSiteConfig();
60+
const { logtoApiBaseUrl, isDevFeatureEnabled, logtoAdminConsoleUrl } = customFields ?? {};
61+
62+
return useMemo(() => {
63+
const baseUrl =
64+
typeof logtoApiBaseUrl === 'string'
65+
? logtoApiBaseUrl
66+
: isDevFeatureEnabled
67+
? defaultApiBaseDevUrl
68+
: defaultApiBaseProdUrl;
69+
70+
return {
71+
baseUrl,
72+
logtoAdminConsoleUrl,
73+
};
74+
}, [logtoApiBaseUrl, isDevFeatureEnabled, logtoAdminConsoleUrl]);
75+
}
76+
77+
export function useGoogleOneTapConfig(): {
78+
config: Optional<GoogleOneTapConfig>;
79+
} {
80+
const [config, setConfig] = useState<GoogleOneTapConfig>();
81+
const { debugLogger } = useDebugLogger();
82+
const {
83+
siteConfig: { customFields },
84+
} = useSiteConfig();
85+
86+
useEffect(() => {
87+
const loadConfig = async () => {
88+
const rawConfig = customFields?.googleOneTapConfig;
89+
if (typeof rawConfig !== 'string') {
90+
throw new TypeError('Google One Tap config is not a string');
91+
}
92+
const parsedConfig = googleOneTapConfigSchema.safeParse(
93+
// eslint-disable-next-line no-restricted-syntax
94+
trySafe(() => JSON.parse(rawConfig) as unknown)
95+
);
96+
97+
if (parsedConfig.success) {
98+
setConfig(parsedConfig.data);
99+
} else {
100+
debugLogger.error('Failed to parse Google One Tap config:', parsedConfig.error);
101+
setConfig(undefined);
102+
}
103+
};
104+
105+
void loadConfig();
106+
}, [customFields?.googleOneTapConfig, debugLogger]);
107+
108+
return { config };
109+
}

src/theme/Layout/index.tsx

Lines changed: 7 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,21 @@
1+
import BrowserOnly from '@docusaurus/BrowserOnly';
12
import type { WrapperProps } from '@docusaurus/types';
2-
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
33
import type LayoutType from '@theme/Layout';
44
import Layout from '@theme-original/Layout';
5-
import type { ReactNode } from 'react';
6-
import { useEffect, useState } from 'react';
7-
import { z } from 'zod';
5+
import { type ReactNode } from 'react';
86

9-
type Props = WrapperProps<typeof LayoutType>;
10-
11-
const oneTapSchema = z
12-
.object({
13-
isEnabled: z.boolean().optional(),
14-
autoSelect: z.boolean().optional(),
15-
closeOnTapOutside: z.boolean().optional(),
16-
itpSupport: z.boolean().optional(),
17-
})
18-
.optional();
19-
20-
const googleOneTapConfigSchema = z.object({
21-
clientId: z.string(),
22-
oneTap: oneTapSchema,
23-
});
24-
25-
type GoogleOneTapConfig = z.infer<typeof googleOneTapConfigSchema>;
7+
import GoogleOneTapInitializer from './GoogleOneTapInitializer';
8+
import { useGoogleOneTapConfig } from './hooks';
269

27-
const CACHE_KEY = '_logto_google_one_tap_config';
28-
const CACHE_EXPIRY_KEY = '_logto_google_one_tap_config_expiry';
29-
const CACHE_EXPIRY_TIME = 1 * 60 * 60 * 1000; // 1 hour
30-
31-
// Default API base URL
32-
const DEFAULT_API_BASE_PROD_URL = 'https://auth.logto.io';
33-
const DEFAULT_API_BASE_DEV_URL = 'https://auth.logto.dev';
10+
type Props = WrapperProps<typeof LayoutType>;
3411

3512
export default function LayoutWrapper(props: Props): ReactNode {
36-
const [config, setConfig] = useState<GoogleOneTapConfig | undefined>(undefined);
37-
const { siteConfig } = useDocusaurusContext();
38-
39-
// Get the API base URL from customFields, or use the default value if it doesn't exist
40-
const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl;
41-
const apiBaseUrl =
42-
typeof logtoApiBaseUrl === 'string'
43-
? logtoApiBaseUrl
44-
: siteConfig.customFields?.isDevFeatureEnabled
45-
? DEFAULT_API_BASE_DEV_URL
46-
: DEFAULT_API_BASE_PROD_URL;
47-
48-
useEffect(() => {
49-
const fetchConfig = async () => {
50-
try {
51-
const cachedConfig = localStorage.getItem(CACHE_KEY);
52-
const cachedExpiry = localStorage.getItem(CACHE_EXPIRY_KEY);
53-
54-
if (cachedConfig && cachedExpiry && Number(cachedExpiry) > Date.now()) {
55-
try {
56-
const parsedConfig = googleOneTapConfigSchema.parse(JSON.parse(cachedConfig));
57-
setConfig(parsedConfig);
58-
return;
59-
} catch (parseError) {
60-
console.error('Cached config validation failed:', parseError);
61-
}
62-
}
63-
64-
const response = await fetch(`${apiBaseUrl}/api/google-one-tap/config`, {
65-
headers: {
66-
Origin: window.location.origin,
67-
},
68-
});
69-
70-
if (!response.ok) {
71-
throw new Error('Failed to fetch Google One Tap config');
72-
}
73-
74-
const data = await response.json();
75-
76-
const validatedConfig = googleOneTapConfigSchema.parse(data);
77-
78-
localStorage.setItem(CACHE_KEY, JSON.stringify(validatedConfig));
79-
localStorage.setItem(CACHE_EXPIRY_KEY, String(Date.now() + CACHE_EXPIRY_TIME));
80-
81-
setConfig(validatedConfig);
82-
} catch (error) {
83-
console.error('Error fetching or validating Google One Tap config:', error);
84-
}
85-
};
86-
87-
void fetchConfig();
88-
}, [apiBaseUrl]);
13+
const { config } = useGoogleOneTapConfig();
8914

9015
return (
9116
<>
92-
{config && config.oneTap?.isEnabled && (
93-
<div
94-
data-itp_support={Boolean(config.oneTap.itpSupport)}
95-
data-auto_select={Boolean(config.oneTap.autoSelect)}
96-
data-cancel_on_tap_outside={Boolean(config.oneTap.closeOnTapOutside)}
97-
id="g_id_onload"
98-
data-client_id={config.clientId}
99-
// TODO: implement handleCredentialResponse page
100-
data-callback="handleCredentialResponse"
101-
data-context="signin"
102-
></div>
103-
)}
10417
<Layout {...props} />
18+
{config?.oneTap?.isEnabled && <BrowserOnly>{() => <GoogleOneTapInitializer />}</BrowserOnly>}
10519
</>
10620
);
10721
}

0 commit comments

Comments
 (0)