diff --git a/packages/rn/src/client.test.ts b/packages/rn/src/client.test.ts new file mode 100644 index 0000000..83ed143 --- /dev/null +++ b/packages/rn/src/client.test.ts @@ -0,0 +1,140 @@ +import { LogtoClient } from './client'; + +const { platform, memoryStore, MemoryStorage, openBrowserAsync, openAuthSessionAsync } = + vitest.hoisted(() => { + const platform = { OS: 'web' }; + const memoryStore = new Map(); + + class MemoryStorage { + constructor(public readonly appId: string) {} + + getKey(item?: string) { + return item === undefined ? this.appId : `${this.appId}:${item}`; + } + + async getItem(key: string) { + return memoryStore.get(this.getKey(key)) ?? null; + } + + async setItem(key: string, value: string) { + memoryStore.set(this.getKey(key), value); + } + + async removeItem(key: string) { + memoryStore.delete(this.getKey(key)); + } + } + + return { + platform, + memoryStore, + MemoryStorage, + openBrowserAsync: vitest.fn(async () => ({ type: 'opened' })), + openAuthSessionAsync: vitest.fn(async () => ({ type: 'cancel' })), + }; + }); + +vitest.mock('react-native', () => ({ Platform: platform })); + +vitest.mock('expo-web-browser', () => ({ openBrowserAsync, openAuthSessionAsync })); + +vitest.mock('./storage', () => ({ BrowserStorage: MemoryStorage, SecureStorage: MemoryStorage })); + +vitest.mock('./utils', () => ({ + generateCodeChallenge: async () => 'code-challenge', + generateRandomString: async () => 'random-string', +})); + +const oidcConfig = { + authorization_endpoint: 'https://logto.example.com/oidc/auth', + token_endpoint: 'https://logto.example.com/oidc/token', + userinfo_endpoint: 'https://logto.example.com/oidc/me', + end_session_endpoint: 'https://logto.example.com/oidc/session/end', + revocation_endpoint: 'https://logto.example.com/oidc/token/revocation', + jwks_uri: 'https://logto.example.com/oidc/jwks', + issuer: 'https://logto.example.com/oidc', +}; + +const config = { endpoint: 'https://logto.example.com', appId: 'app-id' }; +const redirectUri = 'https://app.example.com/callback'; + +const createClient = () => new LogtoClient(config); + +describe('LogtoClient sign-in popup handling', () => { + beforeEach(() => { + memoryStore.clear(); + openBrowserAsync.mockClear(); + openAuthSessionAsync.mockClear(); + // eslint-disable-next-line @silverhand/fp/no-mutation -- reset the mocked platform between tests + platform.OS = 'web'; + // Resolves the OIDC discovery request triggered by `super.signIn`. + vitest.stubGlobal( + 'fetch', + vitest.fn(async () => ({ ok: true, json: async () => oidcConfig })) + ); + }); + + afterEach(() => { + vitest.unstubAllGlobals(); + }); + + it('opens a named blank window before the auth session on web', async () => { + // `openAuthSessionAsync` returns `cancel`, so sign-in throws after the window is opened. + await expect(createClient().signIn(redirectUri)).rejects.toThrow(); + + expect(openBrowserAsync).toHaveBeenCalledWith('', { windowName: 'logtoAuth' }); + expect(openAuthSessionAsync).toHaveBeenCalledWith( + expect.any(String), + redirectUri, + expect.objectContaining({ windowName: 'logtoAuth' }) + ); + // The blank window must be opened while the click gesture is alive, i.e. before the auth session. + expect(openBrowserAsync.mock.invocationCallOrder[0]).toBeLessThan( + openAuthSessionAsync.mock.invocationCallOrder[0]! + ); + }); + + it('does not pre-open a window on native platforms', async () => { + // eslint-disable-next-line @silverhand/fp/no-mutation -- switch the mocked platform to native + platform.OS = 'ios'; + + await expect(createClient().signIn(redirectUri)).rejects.toThrow(); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(openAuthSessionAsync).toHaveBeenCalledTimes(1); + }); + + it('opts out of the workaround when disableWebPopupWorkaround is set', async () => { + const client = new LogtoClient({ ...config, disableWebPopupWorkaround: true }); + + await expect(client.signIn(redirectUri)).rejects.toThrow(); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + // No reuse name is passed, so the auth session opens a fresh window as before the workaround. + expect(openAuthSessionAsync).toHaveBeenCalledWith( + expect.any(String), + redirectUri, + expect.not.objectContaining({ windowName: 'logtoAuth' }) + ); + }); + + it('closes the pre-opened window when sign-in fails before the auth session', async () => { + const close = vitest.fn(); + const open = vitest.fn(() => ({ close })); + vitest.stubGlobal('window', { open }); + // Fail OIDC discovery so `super.signIn` rejects before `openAuthSessionAsync` runs. + vitest.stubGlobal( + 'fetch', + vitest.fn(async () => { + throw new Error('network error'); + }) + ); + + await expect(createClient().signIn(redirectUri)).rejects.toThrow('network error'); + + expect(openBrowserAsync).toHaveBeenCalledWith('', { windowName: 'logtoAuth' }); + expect(openAuthSessionAsync).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith('', 'logtoAuth'); + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/rn/src/client.ts b/packages/rn/src/client.ts index 4dbc700..4dde85d 100644 --- a/packages/rn/src/client.ts +++ b/packages/rn/src/client.ts @@ -17,6 +17,10 @@ import { generateCodeChallenge, generateRandomString } from './utils'; const issuedAtTimeTolerance = 300; // 5 minutes +// On web, the sign-in window is opened by name so a pre-opened blank popup can be reused. +// See `signIn` below for why this is needed. +const webAuthWindowName = 'logtoAuth'; + export type LogtoNativeConfig = LogtoConfig & { /** * The prompt to be used for the authentication request. This can be used to skip the login or @@ -37,11 +41,26 @@ export type LogtoNativeConfig = LogtoConfig & { * @default true */ preferEphemeralSession?: boolean; + /** + * **Only for web** + * + * On web, the sign-in flow opens the authorization window via `window.open`. Because the URL is + * only known after async work (OIDC discovery, PKCE), Safari and Firefox no longer treat the call + * as user-initiated and block it as a popup. To work around this, the SDK opens a blank window + * synchronously when sign-in starts and reuses it for the authorization request. + * + * Set this to `true` to opt out of the workaround and fall back to opening the window directly, + * e.g. if it conflicts with your own popup handling. Has no effect on native platforms. + * + * @default false + */ + disableWebPopupWorkaround?: boolean; }; export class LogtoClient extends StandardLogtoClient { authSessionResult?: WebBrowser.WebBrowserAuthSessionResult; protected storage: SecureStorage | BrowserStorage; + protected readonly webPopupWorkaroundEnabled: boolean; constructor(config: LogtoNativeConfig) { const storage = @@ -50,6 +69,7 @@ export class LogtoClient extends StandardLogtoClient { : new SecureStorage(`logto.${config.appId}`); const requester = createRequester(fetch); + const webPopupWorkaroundEnabled = !(config.disableWebPopupWorkaround ?? false); super( { prompt: [Prompt.Consent], ...config }, @@ -62,6 +82,9 @@ export class LogtoClient extends StandardLogtoClient { this.authSessionResult = await WebBrowser.openAuthSessionAsync(url, redirectUri, { preferEphemeralSession: config.preferEphemeralSession ?? true, createTask: false, + // Reuse the popup pre-opened in `signIn` on web; `undefined` (the default) when the + // workaround is off, and ignored on native. + windowName: webPopupWorkaroundEnabled ? webAuthWindowName : undefined, }); break; } @@ -102,6 +125,7 @@ export class LogtoClient extends StandardLogtoClient { ); this.storage = storage; + this.webPopupWorkaroundEnabled = webPopupWorkaroundEnabled; } /** @@ -136,9 +160,28 @@ export class LogtoClient extends StandardLogtoClient { options: SignInOptions | string, interactionMode?: InteractionMode ): Promise { - await (typeof options === 'string' - ? super.signIn(options, interactionMode) - : super.signIn(options)); + const usePopupWorkaround = Platform.OS === 'web' && this.webPopupWorkaroundEnabled; + + if (usePopupWorkaround) { + // `super.signIn` awaits OIDC config, PKCE and storage before opening the auth window, by + // which point the click's user activation is gone and Safari blocks the popup. Open a named + // blank window now, while the activation is still alive; `openAuthSessionAsync` later reuses + // it by name, which the browser treats as a navigation rather than a new popup. + await WebBrowser.openBrowserAsync('', { windowName: webAuthWindowName }); + } + + try { + await (typeof options === 'string' + ? super.signIn(options, interactionMode) + : super.signIn(options)); + } catch (error: unknown) { + if (usePopupWorkaround) { + // Re-target the named window (empty URL doesn't navigate it) and close it, so a sign-in + // that fails before the auth window opens doesn't leave a blank popup behind. + window.open('', webAuthWindowName)?.close(); + } + throw error; + } if (this.authSessionResult?.type !== 'success') { throw new LogtoNativeClientError('auth_session_failed');