Skip to content
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
140 changes: 140 additions & 0 deletions packages/rn/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { LogtoClient } from './client';

const { platform, memoryStore, MemoryStorage, openBrowserAsync, openAuthSessionAsync } =
vitest.hoisted(() => {
const platform = { OS: 'web' };
const memoryStore = new Map<string, string>();

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);
});
});
49 changes: 46 additions & 3 deletions packages/rn/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 =
Expand All @@ -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 },
Expand All @@ -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;
}
Expand Down Expand Up @@ -102,6 +125,7 @@ export class LogtoClient extends StandardLogtoClient {
);

this.storage = storage;
this.webPopupWorkaroundEnabled = webPopupWorkaroundEnabled;
}

/**
Expand Down Expand Up @@ -136,9 +160,28 @@ export class LogtoClient extends StandardLogtoClient {
options: SignInOptions | string,
interactionMode?: InteractionMode
): Promise<void> {
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');
Expand Down
Loading