diff --git a/packages/game-bridge/src/index.ts b/packages/game-bridge/src/index.ts index 211fbd776d..cdc036366c 100644 --- a/packages/game-bridge/src/index.ts +++ b/packages/game-bridge/src/index.ts @@ -361,8 +361,8 @@ window.callFunction = async (jsonData: string) => { } case PASSPORT_FUNCTIONS.getPKCEAuthUrl: { const request = data ? JSON.parse(data) : {}; - const directLoginMethod = request?.directLoginMethod; - const url = await getPassportClient().loginWithPKCEFlow(directLoginMethod); + const directLoginOptions: passport.DirectLoginOptions | undefined = request?.directLoginOptions; + const url = await getPassportClient().loginWithPKCEFlow(directLoginOptions); trackDuration(moduleName, 'performedGetPkceAuthUrl', mt(markStart)); callbackToGame({ responseFor: fxName, diff --git a/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx b/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx index 9fd048401c..c89f6a6eb0 100644 --- a/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx +++ b/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx @@ -179,7 +179,7 @@ export function PassportProvider({ const popupRedirectGoogle = useCallback(async () => { try { setIsLoading(true); - const userProfile = await passportClient.login({ directLoginMethod: 'google' }); + const userProfile = await passportClient.login({ directLoginOptions: { method: 'google' } }); addMessage('Popup Login (Google)', userProfile); } catch (err) { addMessage('Popup Login (Google)', err); @@ -192,7 +192,7 @@ export function PassportProvider({ const popupRedirectApple = useCallback(async () => { try { setIsLoading(true); - const userProfile = await passportClient.login({ directLoginMethod: 'apple' }); + const userProfile = await passportClient.login({ directLoginOptions: { method: 'apple' } }); addMessage('Popup Login (Apple)', userProfile); } catch (err) { addMessage('Popup Login (Apple)', err); @@ -205,7 +205,7 @@ export function PassportProvider({ const popupRedirectFacebook = useCallback(async () => { try { setIsLoading(true); - const userProfile = await passportClient.login({ directLoginMethod: 'facebook' }); + const userProfile = await passportClient.login({ directLoginOptions: { method: 'facebook' } }); addMessage('Popup Login (Facebook)', userProfile); } catch (err) { addMessage('Popup Login (Facebook)', err); @@ -220,7 +220,7 @@ export function PassportProvider({ try { setIsLoading(true); const userProfile = await passportClient.login({ - directLoginMethod: 'google', + directLoginOptions: { method: 'google' }, useRedirectFlow: true, }); addMessage('Login (Google)', userProfile); @@ -236,7 +236,7 @@ export function PassportProvider({ try { setIsLoading(true); const userProfile = await passportClient.login({ - directLoginMethod: 'apple', + directLoginOptions: { method: 'apple' }, useRedirectFlow: true, }); addMessage('Login (Apple)', userProfile); @@ -252,7 +252,7 @@ export function PassportProvider({ try { setIsLoading(true); const userProfile = await passportClient.login({ - directLoginMethod: 'facebook', + directLoginOptions: { method: 'facebook' }, useRedirectFlow: true, }); addMessage('Login (Facebook)', userProfile); diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index 97385925e2..14806242ea 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -16,7 +16,7 @@ import { PassportImxProviderFactory } from './starkEx'; import { PassportConfiguration } from './config'; import { DeviceTokenResponse, - DirectLoginMethod, + DirectLoginOptions, isUserImx, isUserZkEvm, LinkedWallet, @@ -192,7 +192,7 @@ export class Passport { * @param {boolean} [options.useSilentLogin] - If true, attempts silent authentication without user interaction. * Note: This takes precedence over useCachedSession if both are true * @param {boolean} [options.useRedirectFlow] - If true, uses redirect flow instead of popup flow - * @param {DirectLoginMethod} [options.directLoginMethod] - If provided, directly redirects to the specified login method + * @param {DirectLoginOptions} [options.directLoginOptions] - Type-safe login options. For email login, use { method: 'email', email: 'user@example.com' }. For social login, use { method: 'google' } etc. * @returns {Promise} A promise that resolves to the user profile if logged in, null otherwise * @throws {Error} If retrieving the cached user session fails (except for "Unknown or invalid refresh token" errors) * and useCachedSession is true @@ -202,7 +202,7 @@ export class Passport { anonymousId?: string; useSilentLogin?: boolean; useRedirectFlow?: boolean; - directLoginMethod?: DirectLoginMethod; + directLoginOptions?: DirectLoginOptions; }): Promise { // If there's already a login in progress, return that promise if (this.#loginPromise) { @@ -230,9 +230,9 @@ export class Passport { user = await this.authManager.forceUserRefresh(); } else if (!user && !useCachedSession) { if (options?.useRedirectFlow) { - await this.authManager.loginWithRedirect(options?.anonymousId, options?.directLoginMethod); + await this.authManager.loginWithRedirect(options?.anonymousId, options?.directLoginOptions); } else { - user = await this.authManager.login(options?.anonymousId, options?.directLoginMethod); + user = await this.authManager.login(options?.anonymousId, options?.directLoginOptions); } } @@ -273,11 +273,11 @@ export class Passport { /** * Initiates a PKCE flow login. - * @param {DirectLoginMethod} [directLoginMethod] - If provided, directly redirects to the specified login method + * @param {DirectLoginOptions} [directLoginOptions] - Type-safe login options. For email login, use { method: 'email', email: 'user@example.com' }. For social login, use { method: 'google' } etc. * @returns {string} The authorization URL for the PKCE flow */ - public loginWithPKCEFlow(directLoginMethod?: DirectLoginMethod): Promise { - return withMetricsAsync(async () => await this.authManager.getPKCEAuthorizationUrl(directLoginMethod), 'loginWithPKCEFlow'); + public loginWithPKCEFlow(directLoginOptions?: DirectLoginOptions): Promise { + return withMetricsAsync(async () => await this.authManager.getPKCEAuthorizationUrl(directLoginOptions), 'loginWithPKCEFlow'); } /** diff --git a/packages/passport/sdk/src/authManager.test.ts b/packages/passport/sdk/src/authManager.test.ts index be70e0859f..1ca7bddaa2 100644 --- a/packages/passport/sdk/src/authManager.test.ts +++ b/packages/passport/sdk/src/authManager.test.ts @@ -830,30 +830,39 @@ describe('AuthManager', () => { expect(url.searchParams.get('direct')).toBeNull(); }); - it('should include direct parameter when directLoginMethod is provided', async () => { - const directLoginMethod = 'apple'; - const result = await authManager.getPKCEAuthorizationUrl(directLoginMethod); + it('should include direct parameter when directLoginOptions is provided', async () => { + const directLoginOptions = { method: 'apple' as const }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); const url = new URL(result); expect(url.searchParams.get('direct')).toEqual('apple'); }); it('should include direct parameter for google login method', async () => { - const directLoginMethod = 'google'; - const result = await authManager.getPKCEAuthorizationUrl(directLoginMethod); + const directLoginOptions = { method: 'google' as const }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); const url = new URL(result); expect(url.searchParams.get('direct')).toEqual('google'); }); it('should include direct parameter for facebook login method', async () => { - const directLoginMethod = 'facebook'; - const result = await authManager.getPKCEAuthorizationUrl(directLoginMethod); + const directLoginOptions = { method: 'facebook' as const }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); const url = new URL(result); expect(url.searchParams.get('direct')).toEqual('facebook'); }); + it('should handle email login with email parameter', async () => { + const directLoginOptions = { method: 'email' as const, email: 'test@example.com' }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); + const url = new URL(result); + + expect(url.searchParams.get('direct')).toEqual('email'); + expect(url.searchParams.get('email')).toEqual('test@example.com'); + }); + it('should include audience parameter when specified in config', async () => { const configWithAudience = getConfig({ audience: 'test-audience' }); const am = new AuthManager(configWithAudience); @@ -868,19 +877,75 @@ describe('AuthManager', () => { const configWithAudience = getConfig({ audience: 'test-audience' }); const am = new AuthManager(configWithAudience); - const result = await am.getPKCEAuthorizationUrl('apple'); + const result = await am.getPKCEAuthorizationUrl({ method: 'apple' as const }); const url = new URL(result); expect(url.searchParams.get('direct')).toEqual('apple'); expect(url.searchParams.get('audience')).toEqual('test-audience'); }); + + describe('email validation logic', () => { + it('should not include direct/email parameters when email method has empty email', async () => { + const directLoginOptions = { method: 'email' as const, email: '' }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); + const url = new URL(result); + + expect(url.searchParams.get('direct')).toBeNull(); + expect(url.searchParams.get('email')).toBeNull(); + }); + + it('should not include direct/email parameters when email method has undefined email', async () => { + const directLoginOptions = { method: 'email' as const, email: undefined as any }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); + const url = new URL(result); + + expect(url.searchParams.get('direct')).toBeNull(); + expect(url.searchParams.get('email')).toBeNull(); + }); + + it('should not include direct/email parameters when email method has "undefined" string email', async () => { + const directLoginOptions = { method: 'email' as const, email: 'undefined' }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); + const url = new URL(result); + + expect(url.searchParams.get('direct')).toBeNull(); + expect(url.searchParams.get('email')).toBeNull(); + }); + + it('should not include direct/email parameters when email method has "null" string email', async () => { + const directLoginOptions = { method: 'email' as const, email: 'null' }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); + const url = new URL(result); + + expect(url.searchParams.get('direct')).toBeNull(); + expect(url.searchParams.get('email')).toBeNull(); + }); + + it('should include both direct and email parameters when email method has valid email', async () => { + const directLoginOptions = { method: 'email' as const, email: 'user@example.com' }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); + const url = new URL(result); + + expect(url.searchParams.get('direct')).toEqual('email'); + expect(url.searchParams.get('email')).toEqual('user@example.com'); + }); + + it('should include direct parameter for social login methods regardless of email field', async () => { + const directLoginOptions = { method: 'google' as const }; + const result = await authManager.getPKCEAuthorizationUrl(directLoginOptions); + const url = new URL(result); + + expect(url.searchParams.get('direct')).toEqual('google'); + expect(url.searchParams.get('email')).toBeNull(); + }); + }); }); - describe('login with directLoginMethod', () => { + describe('login with directLoginOptions', () => { it('should pass directLoginMethod to login popup', async () => { mockSigninPopup.mockResolvedValue(mockOidcUser); - await authManager.login('anonymous-id', 'apple'); + await authManager.login('anonymous-id', { method: 'apple' as const }); expect(mockSigninPopup).toHaveBeenCalledWith({ extraQueryParams: { @@ -896,6 +961,26 @@ describe('AuthManager', () => { }); }); + it('should handle email login with email parameter', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { method: 'email', email: 'test@example.com' }); + + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'email', + email: 'test@example.com', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: 'passportLoginPrompt', + }); + }); + it('should not include direct parameter when directLoginMethod is not provided', async () => { mockSigninPopup.mockResolvedValue(mockOidcUser); @@ -913,9 +998,122 @@ describe('AuthManager', () => { popupWindowTarget: 'passportLoginPrompt', }); }); + + describe('email validation logic for login popup', () => { + it('should not include direct/email parameters when email method has empty email', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { method: 'email', email: '' }); + + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: 'passportLoginPrompt', + }); + }); + + it('should not include direct/email parameters when email method has undefined email', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { method: 'email', email: undefined as any }); + + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: 'passportLoginPrompt', + }); + }); + + it('should not include direct/email parameters when email method has "undefined" string email', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { method: 'email', email: 'undefined' }); + + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: 'passportLoginPrompt', + }); + }); + + it('should not include direct/email parameters when email method has "null" string email', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { method: 'email', email: 'null' }); + + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: 'passportLoginPrompt', + }); + }); + + it('should include both direct and email parameters when email method has valid email', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { method: 'email', email: 'user@example.com' }); + + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'email', + email: 'user@example.com', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: 'passportLoginPrompt', + }); + }); + + it('should include direct parameter for social login methods', async () => { + mockSigninPopup.mockResolvedValue(mockOidcUser); + + await authManager.login('anonymous-id', { method: 'google' }); + + expect(mockSigninPopup).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'google', + }, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget: 'passportLoginPrompt', + }); + }); + }); }); - describe('loginWithRedirect with directLoginMethod', () => { + describe('loginWithRedirect with directLoginOptions', () => { let mockSigninRedirect: jest.Mock; beforeEach(() => { @@ -937,7 +1135,7 @@ describe('AuthManager', () => { }); it('should pass directLoginMethod to redirect login', async () => { - await authManager.loginWithRedirect('anonymous-id', 'google'); + await authManager.loginWithRedirect('anonymous-id', { method: 'google' as const }); expect(mockSigninRedirect).toHaveBeenCalledWith({ extraQueryParams: { @@ -948,6 +1146,19 @@ describe('AuthManager', () => { }); }); + it('should handle email login with email parameter for redirect', async () => { + await authManager.loginWithRedirect('anonymous-id', { method: 'email', email: 'test@example.com' }); + + expect(mockSigninRedirect).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'email', + email: 'test@example.com', + }, + }); + }); + it('should not include direct parameter when directLoginMethod is not provided', async () => { await authManager.loginWithRedirect('anonymous-id'); @@ -958,5 +1169,76 @@ describe('AuthManager', () => { }, }); }); + + describe('email validation logic for redirect login', () => { + it('should not include direct/email parameters when email method has empty email', async () => { + await authManager.loginWithRedirect('anonymous-id', { method: 'email', email: '' }); + + expect(mockSigninRedirect).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + }); + }); + + it('should not include direct/email parameters when email method has undefined email', async () => { + await authManager.loginWithRedirect('anonymous-id', { method: 'email', email: undefined as any }); + + expect(mockSigninRedirect).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + }); + }); + + it('should not include direct/email parameters when email method has "undefined" string email', async () => { + await authManager.loginWithRedirect('anonymous-id', { method: 'email', email: 'undefined' }); + + expect(mockSigninRedirect).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + }); + }); + + it('should not include direct/email parameters when email method has "null" string email', async () => { + await authManager.loginWithRedirect('anonymous-id', { method: 'email', email: 'null' }); + + expect(mockSigninRedirect).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + }, + }); + }); + + it('should include both direct and email parameters when email method has valid email', async () => { + await authManager.loginWithRedirect('anonymous-id', { method: 'email', email: 'user@example.com' }); + + expect(mockSigninRedirect).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'email', + email: 'user@example.com', + }, + }); + }); + + it('should include direct parameter for social login methods', async () => { + await authManager.loginWithRedirect('anonymous-id', { method: 'apple' }); + + expect(mockSigninRedirect).toHaveBeenCalledWith({ + extraQueryParams: { + rid: '', + third_party_a_id: 'anonymous-id', + direct: 'apple', + }, + }); + }); + }); }); }); diff --git a/packages/passport/sdk/src/authManager.ts b/packages/passport/sdk/src/authManager.ts index 0bd311a278..84bd02dc24 100644 --- a/packages/passport/sdk/src/authManager.ts +++ b/packages/passport/sdk/src/authManager.ts @@ -16,7 +16,7 @@ import logger from './utils/logger'; import { isAccessTokenExpiredOrExpiring } from './utils/token'; import { PassportError, PassportErrorType, withPassportError } from './errors/passportError'; import { - DirectLoginMethod, + DirectLoginOptions, PassportMetadata, User, DeviceTokenResponse, @@ -177,19 +177,42 @@ export default class AuthManager { }); }; - private buildExtraQueryParams(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Record { - return { + /** + * Builds query parameters for authentication requests. + * + * @param anonymousId - Optional anonymous ID for metrics + * @param directLoginOptions - Direct login options + * @returns Query parameters for the authentication request + */ + private buildExtraQueryParams(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Record { + const params: Record = { ...(this.userManager.settings?.extraQueryParams ?? {}), rid: getDetail(Detail.RUNTIME_ID) || '', third_party_a_id: anonymousId || '', - ...(directLoginMethod && { direct: directLoginMethod }), }; + + if (directLoginOptions) { + // If method is email, only include direct login params if email is valid + if (directLoginOptions.method === 'email') { + const emailValue = (directLoginOptions as { method: 'email'; email: string }).email; + if (emailValue && emailValue !== 'undefined' && emailValue !== 'null') { + params.direct = directLoginOptions.method; + params.email = emailValue; + } + // If email method but no valid email, disregard both direct and email params + } else { + // For non-email methods (social login), always include direct param + params.direct = directLoginOptions.method; + } + } + + return params; } - public async loginWithRedirect(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Promise { + public async loginWithRedirect(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise { await this.userManager.clearStaleState(); return withPassportError(async () => { - const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginMethod); + const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions); await this.userManager.signinRedirect({ extraQueryParams, @@ -198,14 +221,19 @@ export default class AuthManager { } /** - * login - * @param anonymousId Caller can pass an anonymousId if they want to associate their user's identity with immutable's internal instrumentation. + * Initiates the login process with optional direct login options. + * + * @param anonymousId - Caller can pass an anonymousId if they want to associate their user's identity with immutable's internal instrumentation. + * @param directLoginOptions - Direct login options: + * - For email login: { method: 'email', email: 'user@example.com' } + * - For social login: { method: 'google' | 'apple' | 'facebook' | ... } + * @returns Promise that resolves to the authenticated user */ - public async login(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Promise { + public async login(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise { return withPassportError(async () => { const popupWindowTarget = 'passportLoginPrompt'; const signinPopup = async () => { - const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginMethod); + const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions); return this.userManager.signinPopup({ extraQueryParams, @@ -289,7 +317,7 @@ export default class AuthManager { }, PassportErrorType.AUTHENTICATION_ERROR); } - public async getPKCEAuthorizationUrl(directLoginMethod?: DirectLoginMethod): Promise { + public async getPKCEAuthorizationUrl(directLoginOptions?: DirectLoginOptions): Promise { const verifier = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32))); const challenge = base64URLEncode(await sha256(verifier)); @@ -312,7 +340,21 @@ export default class AuthManager { if (scope) pKCEAuthorizationUrl.searchParams.set('scope', scope); if (audience) pKCEAuthorizationUrl.searchParams.set('audience', audience); - if (directLoginMethod) pKCEAuthorizationUrl.searchParams.set('direct', directLoginMethod); + + if (directLoginOptions) { + // If method is email, only include direct login params if email is valid + if (directLoginOptions.method === 'email') { + const emailValue = (directLoginOptions as { method: 'email'; email: string }).email; + if (emailValue && emailValue !== 'undefined' && emailValue !== 'null') { + pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.method); + pKCEAuthorizationUrl.searchParams.set('email', emailValue); + } + // If email method but no valid email, disregard both direct and email params + } else { + // For non-email methods (social login), always include direct param + pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.method); + } + } return pKCEAuthorizationUrl.toString(); } diff --git a/packages/passport/sdk/src/index.ts b/packages/passport/sdk/src/index.ts index bd05f743ed..dc2ddb3822 100644 --- a/packages/passport/sdk/src/index.ts +++ b/packages/passport/sdk/src/index.ts @@ -23,4 +23,5 @@ export type { PassportOverrides, PassportModuleConfiguration, DeviceTokenResponse, + DirectLoginOptions, } from './types'; diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index b0e3bfddcb..baee8951eb 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -10,6 +10,8 @@ import { Flow } from '@imtbl/metrics'; */ export type DirectLoginMethod = string; +export type DirectLoginOptions = { method: string } | { method: 'email'; email: string }; + export enum PassportEvents { LOGGED_OUT = 'loggedOut', LOGGED_IN = 'loggedIn',