diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 1c5daa110..e8959f8c8 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -18,15 +18,19 @@ const getResponseErrorMessage = async (response: Response): Promise => { } }; -export const requestWithFetch = async (...args: Parameters): Promise => { - const response = await fetch(...args); - if (!response.ok) { - throw new LogtoError({ - message: await getResponseErrorMessage(response), - response, - }); - } +export const createRequester = (fetchFunction: typeof fetch = fetch) => { + return async (...args: Parameters): Promise => { + const response = await fetchFunction(...args); + if (!response.ok) { + throw new LogtoError({ + message: await getResponseErrorMessage(response), + response, + }); + } - const data = (await response.json()) as T; - return data; + const data = (await response.json()) as T; + return data; + }; }; + +export type Requester = ReturnType; diff --git a/packages/client/src/discover.ts b/packages/client/src/discover.ts index 5f39a8987..4e6288f7e 100644 --- a/packages/client/src/discover.ts +++ b/packages/client/src/discover.ts @@ -1,6 +1,6 @@ import * as s from 'superstruct'; -import { requestWithFetch } from './api'; +import { createRequester, Requester } from './api'; import { LogtoError } from './errors'; const OIDCConfigurationSchema = s.type({ @@ -22,8 +22,11 @@ const appendSlashIfNeeded = (url: string): string => { return url + '/'; }; -export default async function discover(url: string): Promise { - const response = await requestWithFetch( +export default async function discover( + url: string, + requester: Requester = createRequester() +): Promise { + const response = await requester( `${appendSlashIfNeeded(url)}oidc/.well-known/openid-configuration` ); diff --git a/packages/client/src/grant-token.test.ts b/packages/client/src/grant-token.test.ts index d0a82d855..683409f64 100644 --- a/packages/client/src/grant-token.test.ts +++ b/packages/client/src/grant-token.test.ts @@ -62,11 +62,11 @@ describe('grantTokenByRefreshToken', () => { .post('/oidc/token') .reply(200, successResponse); - const tokenSet = await grantTokenByRefreshToken( - 'https://logto.dev/oidc/token', - 'client_id', - 'refresh_token' - ); + const tokenSet = await grantTokenByRefreshToken({ + endpoint: 'https://logto.dev/oidc/token', + clientId: 'client_id', + refreshToken: 'refresh_token', + }); expect(tokenSet).toMatchObject(successResponse); }); diff --git a/packages/client/src/grant-token.ts b/packages/client/src/grant-token.ts index b79045af8..47cdb30f9 100644 --- a/packages/client/src/grant-token.ts +++ b/packages/client/src/grant-token.ts @@ -1,6 +1,6 @@ import * as s from 'superstruct'; -import { requestWithFetch } from './api'; +import { createRequester, Requester } from './api'; import { LogtoError } from './errors'; const TokenSetParametersSchema = s.type({ @@ -12,7 +12,7 @@ const TokenSetParametersSchema = s.type({ export type TokenSetParameters = s.Infer; -type GrantTokenPayload = { +type GrantTokenByAuthorizationPayload = { endpoint: string; code: string; redirectUri: string; @@ -20,13 +20,10 @@ type GrantTokenPayload = { clientId: string; }; -export const grantTokenByAuthorizationCode = async ({ - endpoint, - code, - redirectUri, - codeVerifier, - clientId, -}: GrantTokenPayload): Promise => { +export const grantTokenByAuthorizationCode = async ( + { endpoint, code, redirectUri, codeVerifier, clientId }: GrantTokenByAuthorizationPayload, + requester: Requester = createRequester() +): Promise => { const parameters = new URLSearchParams(); parameters.append('grant_type', 'authorization_code'); parameters.append('code', code); @@ -34,7 +31,7 @@ export const grantTokenByAuthorizationCode = async ({ parameters.append('code_verifier', codeVerifier); parameters.append('client_id', clientId); - const response = await requestWithFetch(endpoint, { + const response = await requester(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -54,17 +51,22 @@ export const grantTokenByAuthorizationCode = async ({ } }; +type GrantTokenByRefreshTokenPayload = { + endpoint: string; + refreshToken: string; + clientId: string; +}; + export const grantTokenByRefreshToken = async ( - endpoint: string, - clientId: string, - refreshToken: string + { endpoint, clientId, refreshToken }: GrantTokenByRefreshTokenPayload, + requester: Requester = createRequester() ): Promise => { const parameters = new URLSearchParams(); parameters.append('grant_type', 'refresh_token'); parameters.append('client_id', clientId); parameters.append('refresh_token', refreshToken); - const response = await requestWithFetch(endpoint, { + const response = await requester(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 74301fb07..e76e6b5e2 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1,6 +1,3 @@ -// Mock window.location -/* eslint-disable @silverhand/fp/no-delete */ -/* eslint-disable @silverhand/fp/no-mutation */ import { KeyObject } from 'crypto'; import { SignJWT, generateKeyPair } from 'jose'; @@ -75,25 +72,12 @@ describe('LogtoClient', () => { nock(BASE_URL).get('/oidc/.well-known/openid-configuration').reply(200, discoverResponse); }); - const locationBackup = window.location; - - beforeAll(() => { - // Can not spy on `window.location` directly - // @ts-expect-error - delete window.location; - // @ts-expect-error - window.location = { assign: jest.fn() }; - }); - - afterAll(() => { - window.location = locationBackup; - }); - describe('createLogtoClient', () => { test('create an instance', async () => { const logto = await LogtoClient.create({ domain: DOMAIN, clientId: CLIENT_ID, + storage: new MemoryStorage(), }); expect(logto).toBeInstanceOf(LogtoClient); }); @@ -143,13 +127,15 @@ describe('LogtoClient', () => { }); describe('loginWithRedirect', () => { - test('window.location.assign should have been called', async () => { + test('onRedirect should have been called', async () => { + const onRedirect = jest.fn(); const logto = await LogtoClient.create({ domain: DOMAIN, clientId: CLIENT_ID, + storage: new MemoryStorage(), }); - await logto.loginWithRedirect(REDIRECT_URI); - expect(window.location.assign).toHaveBeenCalled(); + await logto.loginWithRedirect(REDIRECT_URI, onRedirect); + expect(onRedirect).toHaveBeenCalled(); }); test('session should be set', async () => { @@ -159,7 +145,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - await logto.loginWithRedirect(REDIRECT_URI); + await logto.loginWithRedirect(REDIRECT_URI, jest.fn()); expect(storage.getItem('LOGTO_SESSION_MANAGER')).toHaveProperty('redirectUri', REDIRECT_URI); expect(storage.getItem('LOGTO_SESSION_MANAGER')).toHaveProperty('codeVerifier'); }); @@ -173,7 +159,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - await logto.loginWithRedirect(REDIRECT_URI); + await logto.loginWithRedirect(REDIRECT_URI, jest.fn()); expect(logto.isLoginRedirect(REDIRECT_CALLBACK)).toBeTruthy(); }); @@ -194,7 +180,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - await logto.loginWithRedirect(REDIRECT_URI); + await logto.loginWithRedirect(REDIRECT_URI, jest.fn()); expect(logto.isLoginRedirect(REDIRECT_URI)).toBeFalsy(); }); @@ -205,7 +191,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - await logto.loginWithRedirect('http://example.com'); + await logto.loginWithRedirect('http://example.com', jest.fn()); expect(logto.isLoginRedirect(REDIRECT_URI)).toBeFalsy(); }); }); @@ -248,7 +234,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - await logto.loginWithRedirect(REDIRECT_URI); + await logto.loginWithRedirect(REDIRECT_URI, jest.fn()); await logto.handleCallback(REDIRECT_CALLBACK); expect(verifyIdToken).toHaveBeenCalled(); }); @@ -260,7 +246,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - await logto.loginWithRedirect(REDIRECT_URI); + await logto.loginWithRedirect(REDIRECT_URI, jest.fn()); await logto.handleCallback(REDIRECT_CALLBACK); expect(storage.getItem('LOGTO_SESSION_MANAGER')).toBeUndefined(); }); @@ -272,7 +258,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - await logto.loginWithRedirect(REDIRECT_URI); + await logto.loginWithRedirect(REDIRECT_URI, jest.fn()); await logto.handleCallback(REDIRECT_CALLBACK); expect(logto.isAuthenticated()).toBeTruthy(); }); @@ -298,6 +284,7 @@ describe('LogtoClient', () => { const logto = await LogtoClient.create({ domain: DOMAIN, clientId: CLIENT_ID, + storage: new MemoryStorage(), }); await expect(logto.getAccessToken()).rejects.toThrow(); }); @@ -314,6 +301,7 @@ describe('LogtoClient', () => { id_token: (await generateIdToken()).idToken, expires_in: -1, }); + // eslint-disable-next-line @silverhand/fp/no-mutation logto = await LogtoClient.create({ domain: DOMAIN, clientId: CLIENT_ID, @@ -332,13 +320,20 @@ describe('LogtoClient', () => { }); describe('logout', () => { - test('window.location.assign should have been called', async () => { + test('onRedirect should have been called', async () => { + const onRedirect = jest.fn(); + const storage = new MemoryStorage(); + storage.setItem(LOGTO_TOKEN_SET_CACHE_KEY, { + ...fakeTokenResponse, + id_token: (await generateIdToken()).idToken, + }); const logto = await LogtoClient.create({ domain: DOMAIN, clientId: CLIENT_ID, + storage, }); - logto.logout(REDIRECT_URI); - expect(window.location.assign).toHaveBeenCalled(); + logto.logout(REDIRECT_URI, onRedirect); + expect(onRedirect).toHaveBeenCalled(); }); test('login session should be cleared', async () => { @@ -349,7 +344,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - logto.logout(REDIRECT_URI); + logto.logout(REDIRECT_URI, jest.fn()); expect(storage.removeItem).toBeCalledWith(SESSION_MANAGER_KEY); }); @@ -365,7 +360,7 @@ describe('LogtoClient', () => { clientId: CLIENT_ID, storage, }); - logto.logout(REDIRECT_URI); + logto.logout(REDIRECT_URI, jest.fn()); expect(storage.removeItem).toBeCalled(); }); }); @@ -399,7 +394,7 @@ describe('LogtoClient', () => { storage, onAuthStateChange, }); - await logto.loginWithRedirect(REDIRECT_URI); + await logto.loginWithRedirect(REDIRECT_URI, jest.fn()); await logto.handleCallback(REDIRECT_CALLBACK); expect(onAuthStateChange).toHaveBeenCalled(); }); @@ -413,7 +408,7 @@ describe('LogtoClient', () => { storage, onAuthStateChange, }); - logto.logout(REDIRECT_URI); + logto.logout(REDIRECT_URI, jest.fn()); expect(onAuthStateChange).toHaveBeenCalled(); }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d8683ebb1..639989192 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,5 +1,6 @@ import { Optional } from '@silverhand/essentials'; +import { createRequester, Requester } from './api'; import { TOKEN_SET_CACHE_KEY } from './constants'; import discover, { OIDCConfiguration } from './discover'; import { generateScope } from './generators'; @@ -24,6 +25,7 @@ export interface ConfigParameters { scope?: string | string[]; storage?: ClientStorage; onAuthStateChange?: () => void; + customFetchFunction?: typeof fetch; } export const appendSlashIfNeeded = (url: string): string => { @@ -41,23 +43,31 @@ export default class LogtoClient { private readonly sessionManager: SessionManager; private readonly storage: ClientStorage; private readonly onAuthStateChange: Optional<() => void>; + private readonly requester: Requester; private tokenSet: Optional; constructor(config: ConfigParameters, oidcConfiguration: OIDCConfiguration) { - const { clientId, scope, storage, onAuthStateChange } = config; + const { clientId, scope, storage, onAuthStateChange, customFetchFunction } = config; this.clientId = clientId; this.onAuthStateChange = onAuthStateChange; this.scope = generateScope(scope); this.oidcConfiguration = oidcConfiguration; this.storage = storage ?? new LocalStorage(); this.sessionManager = new SessionManager(this.storage); + this.requester = createRequester(customFetchFunction); this.createTokenSetFromCache(); } static async create(config: ConfigParameters): Promise { - return new LogtoClient(config, await discover(`https://${config.domain}`)); + return new LogtoClient( + config, + await discover(`https://${config.domain}`, createRequester(config.customFetchFunction)) + ); } - public async loginWithRedirect(redirectUri: string) { + public async loginWithRedirect( + redirectUri: string, + onRedirect: (url: string) => void = window.location.assign + ) { const { url, codeVerifier } = await getLoginUrlAndCodeVerifier({ baseUrl: this.oidcConfiguration.authorization_endpoint, clientId: this.clientId, @@ -65,7 +75,7 @@ export default class LogtoClient { redirectUri, }); this.sessionManager.set({ redirectUri, codeVerifier }); - window.location.assign(url); + onRedirect(url); } public isLoginRedirect(url: string) { @@ -108,13 +118,16 @@ export default class LogtoClient { const { redirectUri, codeVerifier } = session; this.sessionManager.clear(); - const tokenParameters = await grantTokenByAuthorizationCode({ - endpoint: this.oidcConfiguration.token_endpoint, - clientId: this.clientId, - code, - redirectUri, - codeVerifier, - }); + const tokenParameters = await grantTokenByAuthorizationCode( + { + endpoint: this.oidcConfiguration.token_endpoint, + clientId: this.clientId, + code, + redirectUri, + codeVerifier, + }, + this.requester + ); await verifyIdToken( createJWKS(this.oidcConfiguration.jwks_uri), tokenParameters.id_token, @@ -149,9 +162,12 @@ export default class LogtoClient { } const tokenParameters = await grantTokenByRefreshToken( - this.oidcConfiguration.token_endpoint, - this.clientId, - this.tokenSet.refreshToken + { + endpoint: this.oidcConfiguration.token_endpoint, + clientId: this.clientId, + refreshToken: this.tokenSet.refreshToken, + }, + this.requester ); await verifyIdToken( createJWKS(this.oidcConfiguration.jwks_uri), @@ -163,7 +179,7 @@ export default class LogtoClient { return this.tokenSet.accessToken; } - public logout(redirectUri: string) { + public logout(redirectUri: string, onRedirect: (url: string) => void = window.location.assign) { this.sessionManager.clear(); if (this.onAuthStateChange) { this.onAuthStateChange(); @@ -180,7 +196,7 @@ export default class LogtoClient { ); this.tokenSet = undefined; this.storage.removeItem(this.tokenSetCacheKey); - window.location.assign(url); + onRedirect(url); } private get tokenSetCacheKey() {