Skip to content

Commit 8087b48

Browse files
committed
feat: check auth status on cloud console
1 parent 2b9f070 commit 8087b48

File tree

3 files changed

+336
-9
lines changed

3 files changed

+336
-9
lines changed

docusaurus.config.ts

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

57
import {
68
addAliasPlugin,
@@ -19,7 +21,6 @@ import {
1921
} from './docusaurus-common.config';
2022
import ogImageGenerator from './plugins/og-image-generator';
2123
import tutorialGenerator from './plugins/tutorial-generator';
22-
import { yes } from '@silverhand/essentials';
2324

2425
const getLogtoDocsUrl = () =>
2526
isCfPagesPreview
@@ -54,7 +55,10 @@ const config: Config = {
5455
customFields: {
5556
inkeepApiKey: process.env.INKEEP_API_KEY,
5657
logtoApiBaseUrl: process.env.LOGTO_API_BASE_URL,
57-
isDevFeatureEnabled: yes(process.env.IS_DEV_FEATURE_ENABLED),
58+
isDevFeatureEnabled: yes(process.env.IS_DEV_FEATURE_ENABLED ?? null),
59+
isDebuggingEnabled: yes((process.env.IS_DEBUGGING_ENABLED) ?? null),
60+
logtoAdminConsoleUrl: process.env.LOGTO_ADMIN_CONSOLE_URL,
61+
enableAuthStatusCheck: yes((process.env.ENABLE_AUTH_STATUS_CHECK) ?? null),
5862
},
5963

6064
staticDirectories: ['static', 'static-localized/' + currentLocale],

src/theme/Layout/index.tsx

Lines changed: 299 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
/* eslint-disable @silverhand/fp/no-mutation */
12
import type { WrapperProps } from '@docusaurus/types';
23
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
4+
import { condString } from '@silverhand/essentials';
35
import type LayoutType from '@theme/Layout';
46
import Layout from '@theme-original/Layout';
57
import type { ReactNode } from 'react';
68
import { useEffect, useState } from 'react';
79
import { z } from 'zod';
810

11+
import { AuthMessageType, type AuthStatusRequest, type AuthStatusResponse } from './types';
12+
913
type Props = WrapperProps<typeof LayoutType>;
1014

1115
const oneTapSchema = z
@@ -34,8 +38,34 @@ const DEFAULT_API_BASE_DEV_URL = 'https://auth.logto.dev';
3438

3539
export default function LayoutWrapper(props: Props): ReactNode {
3640
const [config, setConfig] = useState<GoogleOneTapConfig | undefined>(undefined);
41+
const [authStatus, setAuthStatus] = useState<boolean | undefined>(undefined);
42+
const [authCheckError, setAuthCheckError] = useState<string | undefined>(undefined);
3743
const { siteConfig } = useDocusaurusContext();
3844

45+
// Check if debug mode is enabled
46+
const isDebugMode = Boolean(siteConfig.customFields?.isDebuggingEnabled);
47+
48+
// Debug logger that only logs when debug mode is enabled
49+
const debugLogger = {
50+
log: (...args: unknown[]) => {
51+
if (isDebugMode) {
52+
console.log(...args);
53+
}
54+
},
55+
warn: (...args: unknown[]) => {
56+
if (isDebugMode) {
57+
console.warn(...args);
58+
}
59+
},
60+
error: (...args: unknown[]) => {
61+
if (isDebugMode) {
62+
console.error(...args);
63+
}
64+
},
65+
};
66+
67+
debugLogger.log('siteConfig.customFields', siteConfig.customFields);
68+
3969
// Get the API base URL from customFields, or use the default value if it doesn't exist
4070
const logtoApiBaseUrl = siteConfig.customFields?.logtoApiBaseUrl;
4171
const apiBaseUrl =
@@ -45,6 +75,201 @@ export default function LayoutWrapper(props: Props): ReactNode {
4575
? DEFAULT_API_BASE_DEV_URL
4676
: DEFAULT_API_BASE_PROD_URL;
4777

78+
// Get auth status checker configuration
79+
const logtoAdminConsoleUrl = siteConfig.customFields?.logtoAdminConsoleUrl;
80+
const enableAuthStatusCheck = siteConfig.customFields?.enableAuthStatusCheck;
81+
82+
const iframeSrc =
83+
typeof logtoAdminConsoleUrl === 'string'
84+
? `${logtoAdminConsoleUrl}/auth-status-checker${condString(siteConfig.customFields?.isDebuggingEnabled && '?debug=true')}`
85+
: undefined;
86+
const authStatusCheckerHost =
87+
typeof logtoAdminConsoleUrl === 'string' ? new URL(logtoAdminConsoleUrl).origin : undefined;
88+
89+
/**
90+
* Function to check admin token status via cross-domain iframe communication
91+
*
92+
* This function creates a hidden iframe, sends a message to check the admin token,
93+
* and returns a promise that resolves with the token status.
94+
*
95+
* @returns Promise that resolves to true if user has admin token, false otherwise
96+
* @throws Error if auth status checker is not configured or request fails
97+
*/
98+
const checkAdminTokenStatus = async (): Promise<boolean> => {
99+
return new Promise((resolve, reject) => {
100+
if (!logtoAdminConsoleUrl || !enableAuthStatusCheck || !iframeSrc) {
101+
reject(new Error('Auth status checker not configured'));
102+
return;
103+
}
104+
105+
const iframe = document.createElement('iframe');
106+
iframe.src = iframeSrc;
107+
108+
if (isDebugMode) {
109+
// Temporarily show iframe for debugging
110+
iframe.style.position = 'fixed';
111+
iframe.style.top = '10px';
112+
iframe.style.right = '10px';
113+
iframe.style.width = '1000px';
114+
iframe.style.height = '1000px';
115+
iframe.style.border = '2px solid red';
116+
iframe.style.zIndex = '9999';
117+
iframe.style.backgroundColor = 'white';
118+
} else {
119+
iframe.style.display = 'none';
120+
}
121+
122+
document.body.append(iframe);
123+
124+
const requestId = Math.random().toString(36).slice(7);
125+
// eslint-disable-next-line @silverhand/fp/no-let, prefer-const
126+
let timeoutId: NodeJS.Timeout;
127+
// eslint-disable-next-line @silverhand/fp/no-let
128+
let messageHandlerAdded = false;
129+
130+
const handleMessage = (event: MessageEvent<AuthStatusResponse>) => {
131+
debugLogger.log('handleMessage received:', {
132+
origin: event.origin,
133+
expectedOrigin: authStatusCheckerHost,
134+
data: event.data,
135+
requestId,
136+
dataType: typeof event.data,
137+
dataKeys: Object.keys(event.data),
138+
});
139+
140+
// Validate origin for security
141+
if (event.origin !== authStatusCheckerHost) {
142+
debugLogger.warn('Origin mismatch:', event.origin, 'vs', authStatusCheckerHost);
143+
return;
144+
}
145+
146+
const { data } = event;
147+
148+
// Validate data structure
149+
if (typeof data !== 'object') {
150+
debugLogger.warn('Invalid message data structure:', data);
151+
return;
152+
}
153+
154+
if (data.requestId !== requestId) {
155+
debugLogger.log('Request ID mismatch, ignoring message:', data.requestId, 'vs', requestId);
156+
return;
157+
}
158+
159+
debugLogger.log('Processing valid response for request:', requestId);
160+
161+
clearTimeout(timeoutId);
162+
if (messageHandlerAdded) {
163+
window.removeEventListener('message', handleMessage);
164+
messageHandlerAdded = false;
165+
}
166+
167+
// In debug mode (when iframe is visible), don't remove iframe immediately
168+
const isIframeVisible = iframe.style.display !== 'none';
169+
if (isIframeVisible) {
170+
// In debug mode, delay removal to allow inspection
171+
iframe.style.border = '2px solid green'; // Change border color to indicate success
172+
setTimeout(() => {
173+
if (document.body.contains(iframe)) {
174+
iframe.remove();
175+
}
176+
}, 5000); // Keep iframe for 5 seconds in debug mode
177+
} else if (document.body.contains(iframe)) {
178+
iframe.remove();
179+
}
180+
181+
switch (data.type) {
182+
case AuthMessageType.ADMIN_TOKEN_STATUS: {
183+
debugLogger.log('Received admin token status:', data.hasToken);
184+
resolve(data.hasToken ?? false);
185+
break;
186+
}
187+
case AuthMessageType.ADMIN_TOKEN_ERROR: {
188+
console.error('Received auth error:', data.error);
189+
reject(new Error(data.error || 'Unknown auth error'));
190+
break;
191+
}
192+
}
193+
};
194+
195+
// Add message listener
196+
window.addEventListener('message', handleMessage);
197+
messageHandlerAdded = true;
198+
199+
iframe.addEventListener('load', () => {
200+
debugLogger.log('iframe loaded successfully, preparing to send message');
201+
debugLogger.log('iframe details:', {
202+
src: iframe.src,
203+
contentWindow: !!iframe.contentWindow,
204+
readyState: iframe.contentDocument?.readyState,
205+
});
206+
207+
// Add a longer delay to ensure iframe content is fully ready and message listeners are set up
208+
setTimeout(() => {
209+
try {
210+
const message: AuthStatusRequest = {
211+
type: AuthMessageType.CHECK_ADMIN_TOKEN,
212+
requestId,
213+
};
214+
215+
debugLogger.log('Sending message to iframe:', {
216+
message,
217+
targetOrigin: authStatusCheckerHost,
218+
iframeContentWindow: !!iframe.contentWindow,
219+
});
220+
221+
iframe.contentWindow?.postMessage(message, authStatusCheckerHost ?? '');
222+
} catch (error) {
223+
clearTimeout(timeoutId);
224+
if (messageHandlerAdded) {
225+
window.removeEventListener('message', handleMessage);
226+
messageHandlerAdded = false;
227+
}
228+
if (document.body.contains(iframe)) {
229+
iframe.remove();
230+
}
231+
reject(
232+
new Error(
233+
`Failed to send message to iframe: ${error instanceof Error ? error.message : 'Unknown error'}`
234+
)
235+
);
236+
}
237+
}, 5000); // Increased delay to 5 seconds to ensure iframe is fully ready
238+
});
239+
240+
// eslint-disable-next-line unicorn/prefer-add-event-listener
241+
iframe.onerror = () => {
242+
clearTimeout(timeoutId);
243+
window.removeEventListener('message', handleMessage);
244+
if (document.body.contains(iframe)) {
245+
iframe.remove();
246+
}
247+
reject(new Error('iframe failed to load'));
248+
};
249+
250+
// Set timeout for the request
251+
timeoutId = setTimeout(() => {
252+
window.removeEventListener('message', handleMessage);
253+
254+
// In debug mode, don't remove iframe immediately on timeout
255+
const isIframeVisible = iframe.style.display !== 'none';
256+
if (isIframeVisible) {
257+
// In debug mode, keep iframe visible for inspection
258+
iframe.style.border = '2px solid orange'; // Change border color to indicate timeout
259+
setTimeout(() => {
260+
if (document.body.contains(iframe)) {
261+
iframe.remove();
262+
}
263+
}, 10_000); // Keep iframe for 10 seconds on timeout in debug mode
264+
} else {
265+
iframe.remove();
266+
}
267+
268+
reject(new Error('Request timeout'));
269+
}, 5000);
270+
});
271+
};
272+
48273
useEffect(() => {
49274
const fetchConfig = async () => {
50275
try {
@@ -57,7 +282,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
57282
setConfig(parsedConfig);
58283
return;
59284
} catch (parseError) {
60-
console.error('Cached config validation failed:', parseError);
285+
debugLogger.error('Cached config validation failed:', parseError);
61286
}
62287
}
63288

@@ -72,24 +297,91 @@ export default function LayoutWrapper(props: Props): ReactNode {
72297
}
73298

74299
const data = await response.json();
75-
76300
const validatedConfig = googleOneTapConfigSchema.parse(data);
77301

78302
localStorage.setItem(CACHE_KEY, JSON.stringify(validatedConfig));
79303
localStorage.setItem(CACHE_EXPIRY_KEY, String(Date.now() + CACHE_EXPIRY_TIME));
80304

81305
setConfig(validatedConfig);
82306
} catch (error) {
83-
console.error('Error fetching or validating Google One Tap config:', error);
307+
debugLogger.error('Error fetching or validating Google One Tap config:', error);
84308
}
85309
};
86310

87311
void fetchConfig();
88312
}, [apiBaseUrl]);
89313

314+
// Check auth status on component mount and set up polling
315+
useEffect(() => {
316+
if (!enableAuthStatusCheck || !authStatusCheckerHost) {
317+
return;
318+
}
319+
320+
const performAuthCheck = async () => {
321+
try {
322+
setAuthCheckError(undefined);
323+
const hasToken = await checkAdminTokenStatus();
324+
setAuthStatus(hasToken);
325+
} catch (error) {
326+
debugLogger.error('Failed to check auth status:', error);
327+
setAuthCheckError(error instanceof Error ? error.message : 'Unknown error');
328+
setAuthStatus(false);
329+
}
330+
};
331+
332+
// Initial check
333+
void performAuthCheck();
334+
335+
// Set up polling every 30 seconds
336+
const pollInterval = setInterval(() => {
337+
void performAuthCheck();
338+
}, 30_000);
339+
340+
// Cleanup interval on unmount
341+
return () => {
342+
clearInterval(pollInterval);
343+
};
344+
}, [enableAuthStatusCheck, authStatusCheckerHost]);
345+
346+
// Expose auth status to global scope for external access
347+
useEffect(() => {
348+
if (typeof window !== 'undefined') {
349+
window.__logtoAuthStatus = {
350+
authStatus,
351+
authCheckError,
352+
checkAdminTokenStatus,
353+
};
354+
}
355+
}, [authStatus, authCheckError]);
356+
357+
// Debug logging in development
358+
useEffect(() => {
359+
debugLogger.log('Logto Auth Status Debug Info:', {
360+
authStatus,
361+
authCheckError,
362+
logtoAdminConsoleUrl,
363+
enableAuthStatusCheck,
364+
authStatusCheckerHost,
365+
iframeSrc,
366+
currentOrigin: window.location.origin,
367+
});
368+
}, [
369+
authStatus,
370+
authCheckError,
371+
logtoAdminConsoleUrl,
372+
enableAuthStatusCheck,
373+
authStatusCheckerHost,
374+
iframeSrc,
375+
]);
376+
90377
return (
91-
<>
92-
{config && config.oneTap?.isEnabled && (
378+
<Layout {...props}>
379+
{/*
380+
* Show Google One Tap if:
381+
* 1. user is not authenticated
382+
* 2. Google One Tap is enabled
383+
*/}
384+
{authStatus === false && config && config.oneTap?.isEnabled && (
93385
<div
94386
data-itp_support={Boolean(config.oneTap.itpSupport)}
95387
data-auto_select={Boolean(config.oneTap.autoSelect)}
@@ -101,7 +393,7 @@ export default function LayoutWrapper(props: Props): ReactNode {
101393
data-context="signin"
102394
></div>
103395
)}
104-
<Layout {...props} />
105-
</>
396+
</Layout>
106397
);
107398
}
399+
/* eslint-enable @silverhand/fp/no-mutation */

0 commit comments

Comments
 (0)