diff --git a/packages/game-bridge/src/index.ts b/packages/game-bridge/src/index.ts index 5e46e9fd6f..c9bf963800 100644 --- a/packages/game-bridge/src/index.ts +++ b/packages/game-bridge/src/index.ts @@ -8,7 +8,6 @@ import { gameBridgeVersionCheck } from '@imtbl/version-check'; const scope = 'openid offline_access profile email transact'; const audience = 'platform_api'; const redirectUri = 'https://localhost:3000/'; // Not required -const logoutRedirectUri = 'https://localhost:3000/'; // Not required const keyFunctionName = 'fxName'; const keyRequestId = 'requestId'; @@ -134,7 +133,7 @@ window.callFunction = async (jsonData: string) => { // eslint-disable-line no-un audience, scope, redirectUri: (redirect ?? redirectUri), - logoutRedirectUri, + logoutRedirectUri: request?.logoutRedirectUri, crossSdkBridgeEnabled: true, }; passportClient = new passport.Passport(passportConfig); @@ -266,11 +265,12 @@ window.callFunction = async (jsonData: string) => { // eslint-disable-line no-un break; } case PASSPORT_FUNCTIONS.logout: { - await passportClient?.logoutDeviceFlow(); + const deviceFlowEndSessionEndpoint = await passportClient?.logoutDeviceFlow(); callbackToGame({ responseFor: fxName, requestId, success: true, + result: deviceFlowEndSessionEndpoint, }); break; } diff --git a/packages/passport/sdk/src/Passport.test.ts b/packages/passport/sdk/src/Passport.test.ts index d74a10730c..796102e962 100644 --- a/packages/passport/sdk/src/Passport.test.ts +++ b/packages/passport/sdk/src/Passport.test.ts @@ -28,6 +28,8 @@ describe('Passport', () => { let authLoginMock: jest.Mock; let loginCallbackMock: jest.Mock; let logoutMock: jest.Mock; + let removeUserMock: jest.Mock; + let getDeviceFlowEndSessionEndpointMock: jest.Mock; let magicLoginMock: jest.Mock; let magicLogoutMock: jest.Mock; let confirmationLogoutMock: jest.Mock; @@ -44,6 +46,8 @@ describe('Passport', () => { confirmationLogoutMock = jest.fn(); magicLogoutMock = jest.fn(); logoutMock = jest.fn(); + removeUserMock = jest.fn(); + getDeviceFlowEndSessionEndpointMock = jest.fn(); getUserMock = jest.fn(); requestRefreshTokenMock = jest.fn(); getProviderMock = jest.fn(); @@ -53,6 +57,8 @@ describe('Passport', () => { login: authLoginMock, loginCallback: loginCallbackMock, logout: logoutMock, + removeUser: removeUserMock, + getDeviceFlowEndSessionEndpoint: getDeviceFlowEndSessionEndpointMock, getUser: getUserMock, requestRefreshTokenAfterRegistration: requestRefreshTokenMock, }); @@ -162,8 +168,22 @@ describe('Passport', () => { await passport.logout(); expect(logoutMock).toBeCalledTimes(1); - expect(confirmationLogoutMock).toBeCalledTimes(1); expect(magicLogoutMock).toBeCalledTimes(1); + expect(confirmationLogoutMock).toBeCalledTimes(1); + }); + }); + + describe('logoutDeviceFlow', () => { + it('should execute logoutDeviceFlow without error and return the device flow end session endpoint', async () => { + const endSessionEndpoint = 'https://test.com/logout'; + getDeviceFlowEndSessionEndpointMock.mockReturnValue(endSessionEndpoint); + + const result = await passport.logoutDeviceFlow(); + + expect(result).toEqual('https://test.com/logout'); + expect(removeUserMock).toHaveBeenCalledTimes(1); + expect(magicLogoutMock).toHaveBeenCalledTimes(1); + expect(getDeviceFlowEndSessionEndpointMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index deb4da1945..65376d2c85 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -121,6 +121,10 @@ export class Passport { return user ? user.profile : null; } + public async loginCallback(): Promise { + return this.authManager.loginCallback(); + } + public async loginWithDeviceFlow(): Promise { return this.authManager.loginWithDeviceFlow(); } @@ -134,10 +138,6 @@ export class Passport { return user.profile; } - public async loginCallback(): Promise { - return this.authManager.loginCallback(); - } - public async logout(): Promise { await this.confirmationScreen.logout(); await this.authManager.logout(); @@ -146,10 +146,18 @@ export class Passport { this.passportEventEmitter.emit(PassportEvents.LOGGED_OUT); } - public async logoutDeviceFlow(): Promise { + /** + * Logs the user out of Passport when using device flow authentication. + * + * @returns {Promise} The device flow end session endpoint. Consumers are responsible for + * opening this URL in the same browser that was used to log the user in. + */ + public async logoutDeviceFlow(): Promise { await this.authManager.removeUser(); await this.magicAdapter.logout(); this.passportEventEmitter.emit(PassportEvents.LOGGED_OUT); + + return this.authManager.getDeviceFlowEndSessionEndpoint(); } /** diff --git a/packages/passport/sdk/src/authManager.test.ts b/packages/passport/sdk/src/authManager.test.ts index 694fdbe67d..08491fc961 100644 --- a/packages/passport/sdk/src/authManager.test.ts +++ b/packages/passport/sdk/src/authManager.test.ts @@ -5,6 +5,7 @@ import { PassportError, PassportErrorType } from './errors/passportError'; import { PassportConfiguration } from './config'; import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks'; import { isTokenExpired } from './utils/token'; +import { PassportModuleConfiguration } from './types'; jest.mock('jwt-decode'); jest.mock('./utils/token'); @@ -15,15 +16,14 @@ jest.mock('oidc-client-ts', () => ({ WebStorageStateStore: jest.fn(), })); -const baseConfig = new ImmutableConfiguration({ - environment: Environment.SANDBOX, -}); -const config = new PassportConfiguration({ - baseConfig, - logoutRedirectUri: 'https://test.com', +const getConfig = (values?: Partial) => new PassportConfiguration({ + baseConfig: new ImmutableConfiguration({ + environment: Environment.SANDBOX, + }), clientId: '11111', redirectUri: 'https://test.com', scope: 'email profile', + ...values, }); const commonOidcUser: OidcUser = { @@ -98,74 +98,62 @@ describe('AuthManager', () => { signinSilent: mockSigninSilent, storeUser: mockStoreUser, }); - authManager = new AuthManager(config); + authManager = new AuthManager(getConfig()); }); describe('constructor', () => { - it('should initialise AuthManager with a configuration containing audience params', () => { - const configWithAudience = new PassportConfiguration({ - baseConfig, - logoutRedirectUri: 'https://test.com', - clientId: '11111', - redirectUri: 'https://test.com', - scope: 'email profile', - audience: 'audience', - }); - - // to work around new being used as a side effect, which would cause a lint failure - const am = new AuthManager(configWithAudience); + it('should initialise AuthManager with the correct default configuration', () => { + const config = getConfig(); + const am = new AuthManager(config); expect(am).toBeDefined(); expect(UserManager).toBeCalledWith({ - authority: configWithAudience.authenticationDomain, - client_id: configWithAudience.oidcConfiguration.clientId, + authority: config.authenticationDomain, + client_id: config.oidcConfiguration.clientId, loadUserInfo: true, mergeClaims: true, metadata: { - authorization_endpoint: `${configWithAudience.authenticationDomain}/authorize`, - token_endpoint: `${configWithAudience.authenticationDomain}/oauth/token`, - userinfo_endpoint: `${configWithAudience.authenticationDomain}/userinfo`, - end_session_endpoint: - `${configWithAudience.authenticationDomain}/v2/logout` - + `?returnTo=${encodeURIComponent( - configWithAudience.oidcConfiguration.logoutRedirectUri, - )}` - + `&client_id=${configWithAudience.oidcConfiguration.clientId}`, + authorization_endpoint: `${config.authenticationDomain}/authorize`, + token_endpoint: `${config.authenticationDomain}/oauth/token`, + userinfo_endpoint: `${config.authenticationDomain}/userinfo`, + end_session_endpoint: `${config.authenticationDomain}/v2/logout` + + `?client_id=${config.oidcConfiguration.clientId}`, }, - popup_redirect_uri: configWithAudience.oidcConfiguration.redirectUri, - redirect_uri: configWithAudience.oidcConfiguration.redirectUri, - scope: configWithAudience.oidcConfiguration.scope, + popup_redirect_uri: config.oidcConfiguration.redirectUri, + redirect_uri: config.oidcConfiguration.redirectUri, + scope: config.oidcConfiguration.scope, userStore: expect.any(WebStorageStateStore), - extraQueryParams: { - audience: configWithAudience.oidcConfiguration.audience, - }, }); }); - }); - it('should initialise AuthManager with the correct default configuration', () => { - // to work around new being used as a side effect, which would cause a lint failure - const am = new AuthManager(config); - expect(am).toBeDefined(); - expect(UserManager).toBeCalledWith({ - authority: config.authenticationDomain, - client_id: config.oidcConfiguration.clientId, - loadUserInfo: true, - mergeClaims: true, - metadata: { - authorization_endpoint: `${config.authenticationDomain}/authorize`, - token_endpoint: `${config.authenticationDomain}/oauth/token`, - userinfo_endpoint: `${config.authenticationDomain}/userinfo`, - end_session_endpoint: - `${config.authenticationDomain}/v2/logout` - + `?returnTo=${encodeURIComponent( - config.oidcConfiguration.logoutRedirectUri, - )}` - + `&client_id=${config.oidcConfiguration.clientId}`, - }, - popup_redirect_uri: config.oidcConfiguration.redirectUri, - redirect_uri: config.oidcConfiguration.redirectUri, - scope: config.oidcConfiguration.scope, - userStore: expect.any(WebStorageStateStore), + describe('when an audience is specified', () => { + it('should initialise AuthManager with a configuration containing audience params', () => { + const configWithAudience = getConfig({ + audience: 'audience', + }); + const am = new AuthManager(configWithAudience); + expect(am).toBeDefined(); + expect(UserManager).toBeCalledWith(expect.objectContaining({ + extraQueryParams: { + audience: configWithAudience.oidcConfiguration.audience, + }, + })); + }); + }); + + describe('when a logoutRedirectUri is specified', () => { + it('should set the endSessionEndpoint `returnTo` and `client_id` query string params', () => { + const configWithLogoutRedirectUri = getConfig({ + logoutRedirectUri: 'https://test.com/logout/callback', + }); + + const am = new AuthManager(configWithLogoutRedirectUri); + expect(am).toBeDefined(); + expect(UserManager).toBeCalledWith(expect.objectContaining({ + metadata: expect.objectContaining({ + end_session_endpoint: 'https://auth.immutable.com/v2/logout?client_id=11111&returnTo=https%3A%2F%2Ftest.com%2Flogout%2Fcallback', + }), + })); + }); }); }); @@ -268,8 +256,9 @@ describe('AuthManager', () => { describe('logout', () => { it('should call redirect logout if logout mode is redirect', async () => { - const configuration = { ...config }; - configuration.oidcConfiguration.logoutMode = 'redirect'; + const configuration = getConfig({ + logoutMode: 'redirect', + }); const manager = new AuthManager(configuration); await manager.logout(); @@ -278,8 +267,9 @@ describe('AuthManager', () => { }); it('should call redirect logout if logout mode is not set', async () => { - const configuration = { ...config }; - configuration.oidcConfiguration.logoutMode = undefined; + const configuration = getConfig({ + logoutMode: undefined, + }); const manager = new AuthManager(configuration); await manager.logout(); @@ -288,8 +278,9 @@ describe('AuthManager', () => { }); it('should call silent logout if logout mode is silent', async () => { - const configuration = { ...config }; - configuration.oidcConfiguration.logoutMode = 'silent'; + const configuration = getConfig({ + logoutMode: 'silent', + }); const manager = new AuthManager(configuration); await manager.logout(); @@ -298,8 +289,9 @@ describe('AuthManager', () => { }); it('should throw an error if user is failed to logout', async () => { - const configuration = { ...config }; - configuration.oidcConfiguration.logoutMode = 'redirect'; + const configuration = getConfig({ + logoutMode: 'redirect', + }); const manager = new AuthManager(configuration); mockSignoutRedirect.mockRejectedValue(new Error(mockErrorMsg)); @@ -402,4 +394,30 @@ describe('AuthManager', () => { }); }); }); + + describe('getDeviceFlowEndSessionEndpoint', () => { + describe('when a logoutRedirectUri is specified', () => { + it('should set the endSessionEndpoint `returnTo` and `client_id` query string params', () => { + const am = new AuthManager(getConfig({ + logoutRedirectUri: 'https://test.com/logout/callback', + })); + + const result = am.getDeviceFlowEndSessionEndpoint(); + + expect(result).toEqual( + 'https://auth.immutable.com/v2/logout?client_id=11111&returnTo=https%3A%2F%2Ftest.com%2Flogout%2Fcallback', + ); + }); + }); + + describe('when no logoutRedirectUri is specified', () => { + it('should return the endSessionEndpoint without a `returnTo` or `client_id` query string params', () => { + const am = new AuthManager(getConfig()); + + const result = am.getDeviceFlowEndSessionEndpoint(); + + expect(result).toEqual('https://auth.immutable.com/v2/logout'); + }); + }); + }); }); diff --git a/packages/passport/sdk/src/authManager.ts b/packages/passport/sdk/src/authManager.ts index feaa7fec8d..5f6313e0be 100644 --- a/packages/passport/sdk/src/authManager.ts +++ b/packages/passport/sdk/src/authManager.ts @@ -29,13 +29,17 @@ const formUrlEncodedHeader = { }, }; -const getAuthConfiguration = ({ - oidcConfiguration, - authenticationDomain, -}: PassportConfiguration): UserManagerSettings => { +const getAuthConfiguration = (config: PassportConfiguration): UserManagerSettings => { + const { authenticationDomain, oidcConfiguration } = config; + const store = typeof window !== 'undefined' ? window.localStorage : new InMemoryWebStorage(); const userStore = new WebStorageStateStore({ store }); + let endSessionEndpoint = `${authenticationDomain}/v2/logout?client_id=${oidcConfiguration.clientId}`; + if (oidcConfiguration.logoutRedirectUri) { + endSessionEndpoint += `&returnTo=${encodeURIComponent(oidcConfiguration.logoutRedirectUri)}`; + } + const baseConfiguration: UserManagerSettings = { authority: authenticationDomain, redirect_uri: oidcConfiguration.redirectUri, @@ -45,10 +49,7 @@ const getAuthConfiguration = ({ authorization_endpoint: `${authenticationDomain}/authorize`, token_endpoint: `${authenticationDomain}/oauth/token`, userinfo_endpoint: `${authenticationDomain}/userinfo`, - end_session_endpoint: - `${authenticationDomain}/v2/logout` - + `?returnTo=${encodeURIComponent(oidcConfiguration.logoutRedirectUri)}` - + `&client_id=${oidcConfiguration.clientId}`, + end_session_endpoint: endSessionEndpoint, }, mergeClaims: true, loadUserInfo: true, @@ -70,6 +71,17 @@ function wait(ms: number) { }); } +function base64URLEncode(str: Buffer) { + return str.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function sha256(buffer: string) { + return crypto.createHash('sha256').update(buffer).digest(); +} + export default class AuthManager { private userManager; @@ -239,23 +251,12 @@ export default class AuthManager { return response.data; } - private static base64URLEncode(str: Buffer) { - return str.toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); - } - - private static sha256(buffer: string) { - return crypto.createHash('sha256').update(buffer).digest(); - } - public getPKCEAuthorizationUrl(): string { - const verifier = AuthManager.base64URLEncode(crypto.randomBytes(32)); - const challenge = AuthManager.base64URLEncode(AuthManager.sha256(verifier)); + const verifier = base64URLEncode(crypto.randomBytes(32)); + const challenge = base64URLEncode(sha256(verifier)); // https://auth0.com/docs/secure/attack-protection/state-parameters - const state = AuthManager.base64URLEncode(crypto.randomBytes(32)); + const state = base64URLEncode(crypto.randomBytes(32)); this.deviceCredentialsManager.savePKCEData({ state, verifier }); return `${this.config.authenticationDomain}/authorize?` @@ -322,6 +323,17 @@ export default class AuthManager { return this.userManager.removeUser(); } + public getDeviceFlowEndSessionEndpoint(): string { + const { authenticationDomain, oidcConfiguration } = this.config; + let endSessionEndpoint = `${authenticationDomain}/v2/logout`; + if (oidcConfiguration.logoutRedirectUri) { + endSessionEndpoint += `?client_id=${oidcConfiguration.clientId}` + + `&returnTo=${encodeURIComponent(oidcConfiguration.logoutRedirectUri)}`; + } + + return endSessionEndpoint; + } + public async logoutSilentCallback(url: string): Promise { return this.userManager.signoutSilentCallback(url); } diff --git a/packages/passport/sdk/src/config/config.ts b/packages/passport/sdk/src/config/config.ts index 0c9585eda9..7019ff7931 100644 --- a/packages/passport/sdk/src/config/config.ts +++ b/packages/passport/sdk/src/config/config.ts @@ -60,7 +60,6 @@ export class PassportConfiguration { }: PassportModuleConfiguration) { validateConfiguration(oidcConfiguration, [ 'clientId', - 'logoutRedirectUri', 'redirectUri', ]); this.oidcConfiguration = oidcConfiguration; diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 91f9ab6b9d..2a435c02f4 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -48,7 +48,7 @@ export enum Networks { export interface OidcConfiguration { clientId: string; - logoutRedirectUri: string; + logoutRedirectUri?: string; logoutMode?: 'redirect' | 'silent'; redirectUri: string; scope?: string;