Skip to content

feat: check auth status on cloud console #1126

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 3 commits into
base: yemq-add-local-google-one-tap
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
5 changes: 5 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// eslint-disable-next-line import/no-unassigned-import
import 'dotenv/config';

import type { Config } from '@docusaurus/types';
import { yes } from '@silverhand/essentials';

Expand Down Expand Up @@ -54,6 +55,10 @@ const config: Config = {
inkeepApiKey: process.env.INKEEP_API_KEY,
logtoApiBaseUrl: process.env.LOGTO_API_BASE_URL,
isDevFeatureEnabled: yes(process.env.IS_DEV_FEATURE_ENABLED),
isDebuggingEnabled: yes(process.env.IS_DEBUGGING_ENABLED),
isIframeVisible: yes(process.env.IS_IFRAME_VISIBLE),
logtoAdminConsoleUrl: process.env.LOGTO_ADMIN_CONSOLE_URL,
enableAuthStatusCheck: yes(process.env.ENABLE_AUTH_STATUS_CHECK),
},

staticDirectories: ['static', 'static-localized/' + currentLocale],
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
"@eslint/eslintrc": "^3.1.0",
"@inkeep/cxkit-react": "^0.5.66",
"@mdx-js/react": "^3.1.0",
"@silverhand/eslint-config": "^6.0.0",
"@silverhand/eslint-config-react": "^6.0.0",
"@silverhand/eslint-config": "^6.0.1",
"@silverhand/eslint-config-react": "^6.0.2",
"@silverhand/essentials": "^2.9.2",
"@silverhand/ts-config": "^6.0.0",
"@silverhand/ts-config-react": "^6.0.0",
Expand All @@ -53,7 +53,7 @@
"docusaurus-plugin-remote-content": "^4.0.0",
"docusaurus-plugin-sass": "^0.2.6",
"dotenv": "^16.4.5",
"eslint": "^9.13.0",
"eslint": "^8.57.1",
"eslint-plugin-mdx": "^3.1.5",
"execa": "^9.5.1",
"https-proxy-agent": "^7.0.5",
Expand Down
550 changes: 267 additions & 283 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

234 changes: 234 additions & 0 deletions src/theme/Layout/auth-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/* eslint-disable @silverhand/fp/no-mutation */
import {
iframeLoadDelay,
requestTimeout,
debugIframeDelay,
debugIframeTimeoutDelay,
} from './constants';
import type { DebugLogger } from './debug-logger';
import { AuthMessageType, type AuthStatusRequest, type AuthStatusResponse } from './types';

export type AuthStatusCheckerOptions = {
logtoAdminConsoleUrl?: string;
enableAuthStatusCheck?: boolean;
isDebugMode: boolean;
isIframeVisible: boolean;
debugLogger: DebugLogger;
};

export type AuthStatusResult = {
authStatus?: boolean;
authCheckError?: string;
};

export function createAuthStatusChecker({
logtoAdminConsoleUrl,
enableAuthStatusCheck,
isDebugMode,
isIframeVisible,
debugLogger,
}: AuthStatusCheckerOptions) {
const iframeSrc =
typeof logtoAdminConsoleUrl === 'string'
? `${logtoAdminConsoleUrl}/auth-status${isDebugMode ? '?debug=true' : ''}`
: undefined;

const authStatusCheckerHost =
typeof logtoAdminConsoleUrl === 'string' ? new URL(logtoAdminConsoleUrl).origin : undefined;

/**
* Function to check admin token status via cross-domain iframe communication
*
* This function creates a hidden iframe, sends a message to check the admin token,
* and returns a promise that resolves with the token status.
*
* @returns Promise that resolves to true if user has admin token, false otherwise
* @throws Error if auth status checker is not configured or request fails
*/
const checkAdminTokenStatus = async (): Promise<boolean> => {
return new Promise((resolve, reject) => {
if (!logtoAdminConsoleUrl || !enableAuthStatusCheck || !iframeSrc) {
reject(new Error('Auth status checker not configured'));
return;
}

const iframe = document.createElement('iframe');
iframe.src = iframeSrc;

if (isIframeVisible) {
// Temporarily show iframe for debugging
iframe.style.position = 'fixed';
iframe.style.top = '10px';
iframe.style.right = '10px';
iframe.style.width = '1000px';
iframe.style.height = '1000px';
iframe.style.border = '2px solid red';
iframe.style.zIndex = '9999';
iframe.style.backgroundColor = 'white';
} else {
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<AuthStatusResponse>) => {
debugLogger.log('handleMessage received:', {
origin: event.origin,
expectedOrigin: authStatusCheckerHost,
data: event.data,
requestId,
dataType: typeof event.data,
dataKeys: Object.keys(event.data),
});

// Validate origin for security
if (event.origin !== authStatusCheckerHost) {
debugLogger.warn('Origin mismatch:', event.origin, 'vs', authStatusCheckerHost);
return;
}

const { data } = event;

// Validate data structure
if (typeof data !== 'object') {
debugLogger.warn('Invalid message data structure:', data);
return;
}

if (data.requestId !== requestId) {
debugLogger.log(
'Request ID mismatch, ignoring message:',
data.requestId,
'vs',
requestId
);
return;
}

debugLogger.log('Processing valid response for request:', requestId);

clearTimeout(timeoutId);
if (messageHandlerAdded) {
window.removeEventListener('message', handleMessage);
messageHandlerAdded = false;
}

// In debug mode (when iframe is visible), don't remove iframe immediately
const isIframeVisible = iframe.style.display !== 'none';
if (isIframeVisible) {
// In debug mode, delay removal to allow inspection
iframe.style.border = '2px solid green'; // Change border color to indicate success
setTimeout(() => {
if (document.body.contains(iframe)) {
iframe.remove();
}
}, debugIframeDelay);
} else if (document.body.contains(iframe)) {
iframe.remove();
}

switch (data.type) {
case AuthMessageType.AdminTokenStatus: {
debugLogger.log('Received admin token status (data):', JSON.stringify(data, null, 2));
resolve(data.isAuthenticated ?? false);
break;
}
case AuthMessageType.AdminTokenError: {
console.error('Received auth error:', data.error);
reject(new Error(data.error || 'Unknown auth error'));
break;
}
}
};

// Add message listener
window.addEventListener('message', handleMessage);
messageHandlerAdded = true;

iframe.addEventListener('load', () => {
debugLogger.log('iframe loaded successfully, preparing to send message');
debugLogger.log('iframe details:', {
src: iframe.src,
contentWindow: Boolean(iframe.contentWindow),
readyState: iframe.contentDocument?.readyState,
});

// Add a delay to ensure iframe content is fully ready and message listeners are set up
setTimeout(() => {
try {
const message: AuthStatusRequest = {
type: AuthMessageType.CheckAdminToken,
requestId,
};

debugLogger.log('Sending message to iframe:', {
message,
targetOrigin: authStatusCheckerHost,
iframeContentWindow: Boolean(iframe.contentWindow),
});

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 message to iframe: ${error instanceof Error ? error.message : 'Unknown error'}`
)
);
}
}, iframeLoadDelay);
});

// eslint-disable-next-line unicorn/prefer-add-event-listener
iframe.onerror = () => {
clearTimeout(timeoutId);
window.removeEventListener('message', handleMessage);
if (document.body.contains(iframe)) {
iframe.remove();
}
reject(new Error('iframe failed to load'));
};

// Set timeout for the request
timeoutId = setTimeout(() => {
window.removeEventListener('message', handleMessage);

// In debug mode, don't remove iframe immediately on timeout
const isIframeVisible = iframe.style.display !== 'none';
if (isIframeVisible) {
// In debug mode, keep iframe visible for inspection
iframe.style.border = '2px solid orange'; // Change border color to indicate timeout
setTimeout(() => {
if (document.body.contains(iframe)) {
iframe.remove();
}
}, debugIframeTimeoutDelay);
} else {
iframe.remove();
}

reject(new Error('Request timeout'));
}, requestTimeout);
});
};

return {
checkAdminTokenStatus,
authStatusCheckerHost,
iframeSrc,
};
}
/* eslint-enable @silverhand/fp/no-mutation */
48 changes: 48 additions & 0 deletions src/theme/Layout/config-fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { cacheKey, cacheExpiryKey, cacheExpiryTime } from './constants';
import type { DebugLogger } from './debug-logger';
import { googleOneTapConfigSchema, type GoogleOneTapConfig } from './google-one-tap';

export type ConfigFetcherOptions = {
apiBaseUrl: string;
debugLogger: DebugLogger;
};

export async function fetchGoogleOneTapConfig({
apiBaseUrl,
debugLogger,
}: ConfigFetcherOptions): Promise<GoogleOneTapConfig | undefined> {
try {
const cachedConfig = localStorage.getItem(cacheKey);
const cachedExpiry = localStorage.getItem(cacheExpiryKey);

if (cachedConfig && cachedExpiry && Number(cachedExpiry) > Date.now()) {
try {
const parsedConfig = googleOneTapConfigSchema.parse(JSON.parse(cachedConfig));
return parsedConfig;
} catch (parseError) {
debugLogger.error('Cached config validation failed:', parseError);
}
}

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

if (!response.ok) {
throw new Error('Failed to fetch Google One Tap config');
}

const data = await response.json();
const validatedConfig = googleOneTapConfigSchema.parse(data);

localStorage.setItem(cacheKey, JSON.stringify(validatedConfig));
localStorage.setItem(cacheExpiryKey, String(Date.now() + cacheExpiryTime));

return validatedConfig;
} catch (error) {
debugLogger.error('Error fetching or validating Google One Tap config:', error);
return undefined;
}
}
12 changes: 12 additions & 0 deletions src/theme/Layout/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const cacheKey = '_logto_google_one_tap_config';
export const cacheExpiryKey = '_logto_google_one_tap_config_expiry';
export const cacheExpiryTime = 1 * 60 * 60 * 1000; // 1 hour

export const defaultApiBaseProdUrl = 'https://auth.logto.io';
export const defaultApiBaseDevUrl = 'https://auth.logto.dev';

export const authStatusPollInterval = 30_000; // 30 seconds
export const iframeLoadDelay = 5000; // 5 seconds
export const requestTimeout = 10_000; // 10 seconds
export const debugIframeDelay = 5000; // 5 seconds for success
export const debugIframeTimeoutDelay = 10_000; // 10 seconds for timeout
25 changes: 25 additions & 0 deletions src/theme/Layout/debug-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type DebugLogger = {
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};

export function createDebugLogger(isDebugMode: boolean): DebugLogger {
return {
log: (...args: unknown[]) => {
if (isDebugMode) {
console.log(...args);
}
},
warn: (...args: unknown[]) => {
if (isDebugMode) {
console.warn(...args);
}
},
error: (...args: unknown[]) => {
if (isDebugMode) {
console.error(...args);
}
},
};
}
11 changes: 11 additions & 0 deletions src/theme/Layout/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
declare global {
interface Window {
__logtoAuthStatus?: {
authStatus?: boolean;
authCheckError?: string;
checkAdminTokenStatus: () => Promise<boolean>;
};
}
}

export {};
17 changes: 17 additions & 0 deletions src/theme/Layout/google-one-tap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from 'zod';

export const oneTapSchema = z
.object({
isEnabled: z.boolean().optional(),
autoSelect: z.boolean().optional(),
closeOnTapOutside: z.boolean().optional(),
itpSupport: z.boolean().optional(),
})
.optional();

export const googleOneTapConfigSchema = z.object({
clientId: z.string(),
oneTap: oneTapSchema,
});

export type GoogleOneTapConfig = z.infer<typeof googleOneTapConfigSchema>;
Loading
Loading