Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/game-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, {
} from 'react';
import { IMXProvider } from '@imtbl/x-provider';
import {
LinkedWallet, LinkWalletParams, Provider, UserProfile,
LinkedWallet, LinkWalletParams, Provider, UserProfile, MarketingConsentStatus,
} from '@imtbl/passport';
import { useImmutableProvider } from '@/context/ImmutableProvider';
import { useStatusProvider } from '@/context/StatusProvider';
Expand Down Expand Up @@ -179,7 +179,12 @@ export function PassportProvider({
const popupRedirectGoogle = useCallback(async () => {
try {
setIsLoading(true);
const userProfile = await passportClient.login({ directLoginMethod: 'google' });
const userProfile = await passportClient.login({
directLoginOptions: {
directLoginMethod: 'google',
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
},
});
addMessage('Popup Login (Google)', userProfile);
} catch (err) {
addMessage('Popup Login (Google)', err);
Expand All @@ -192,7 +197,12 @@ export function PassportProvider({
const popupRedirectApple = useCallback(async () => {
try {
setIsLoading(true);
const userProfile = await passportClient.login({ directLoginMethod: 'apple' });
const userProfile = await passportClient.login({
directLoginOptions: {
directLoginMethod: 'apple',
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
},
});
addMessage('Popup Login (Apple)', userProfile);
} catch (err) {
addMessage('Popup Login (Apple)', err);
Expand All @@ -205,7 +215,12 @@ export function PassportProvider({
const popupRedirectFacebook = useCallback(async () => {
try {
setIsLoading(true);
const userProfile = await passportClient.login({ directLoginMethod: 'facebook' });
const userProfile = await passportClient.login({
directLoginOptions: {
directLoginMethod: 'facebook',
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
},
});
addMessage('Popup Login (Facebook)', userProfile);
} catch (err) {
addMessage('Popup Login (Facebook)', err);
Expand All @@ -220,8 +235,11 @@ export function PassportProvider({
try {
setIsLoading(true);
const userProfile = await passportClient.login({
directLoginMethod: 'google',
useRedirectFlow: true,
directLoginOptions: {
directLoginMethod: 'google',
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
},
});
addMessage('Login (Google)', userProfile);
} catch (err) {
Expand All @@ -236,8 +254,11 @@ export function PassportProvider({
try {
setIsLoading(true);
const userProfile = await passportClient.login({
directLoginMethod: 'apple',
useRedirectFlow: true,
directLoginOptions: {
directLoginMethod: 'apple',
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
},
});
addMessage('Login (Apple)', userProfile);
} catch (err) {
Expand All @@ -252,8 +273,11 @@ export function PassportProvider({
try {
setIsLoading(true);
const userProfile = await passportClient.login({
directLoginMethod: 'facebook',
useRedirectFlow: true,
directLoginOptions: {
directLoginMethod: 'facebook',
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
},
});
addMessage('Login (Facebook)', userProfile);
} catch (err) {
Expand Down
22 changes: 14 additions & 8 deletions packages/passport/sdk/src/Passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import MagicAdapter from './magic/magicAdapter';
import { PassportImxProviderFactory } from './starkEx';
import { PassportConfiguration } from './config';
import {
DirectLoginOptions,
DeviceTokenResponse,
DirectLoginMethod,
isUserImx,
isUserZkEvm,
LinkedWallet,
Expand Down Expand Up @@ -192,7 +192,10 @@ 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] - If provided, contains login method and marketing consent options
* @param {string} [options.directLoginOptions.directLoginMethod] - The login method to use (e.g., 'google', 'apple', 'email')
* @param {MarketingConsentStatus} [options.directLoginOptions.marketingConsentStatus] - Marketing consent status ('opted_in' or 'unsubscribed')
* @param {string} [options.directLoginOptions.email] - Required when directLoginMethod is 'email'
* @returns {Promise<UserProfile | null>} 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
Expand All @@ -202,7 +205,7 @@ export class Passport {
anonymousId?: string;
useSilentLogin?: boolean;
useRedirectFlow?: boolean;
directLoginMethod?: DirectLoginMethod;
directLoginOptions?: DirectLoginOptions;
}): Promise<UserProfile | null> {
// If there's already a login in progress, return that promise
if (this.#loginPromise) {
Expand Down Expand Up @@ -230,9 +233,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);
}
}

Expand Down Expand Up @@ -273,11 +276,14 @@ export class Passport {

/**
* Initiates a PKCE flow login.
* @param {DirectLoginMethod} [directLoginMethod] - If provided, directly redirects to the specified login method
* @param {DirectLoginOptions} [directLoginOptions] - If provided, directly redirects to the specified login method
* @returns {string} The authorization URL for the PKCE flow
*/
public loginWithPKCEFlow(directLoginMethod?: DirectLoginMethod): Promise<string> {
return withMetricsAsync(async () => await this.authManager.getPKCEAuthorizationUrl(directLoginMethod), 'loginWithPKCEFlow');
public loginWithPKCEFlow(directLoginOptions?: DirectLoginOptions): Promise<string> {
return withMetricsAsync(
async () => await this.authManager.getPKCEAuthorizationUrl(directLoginOptions),
'loginWithPKCEFlow',
);
}

/**
Expand Down
15 changes: 8 additions & 7 deletions packages/passport/sdk/src/authManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { PassportError, PassportErrorType } from './errors/passportError';
import { PassportConfiguration } from './config';
import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks';
import { isAccessTokenExpiredOrExpiring } from './utils/token';
import { isUserZkEvm, PassportModuleConfiguration } from './types';
import { isUserZkEvm, MarketingConsentStatus, PassportModuleConfiguration } from './types';

jest.mock('jwt-decode');
jest.mock('oidc-client-ts', () => ({
Expand Down Expand Up @@ -832,23 +832,23 @@ describe('AuthManager', () => {

it('should include direct parameter when directLoginMethod is provided', async () => {
const directLoginMethod = 'apple';
const result = await authManager.getPKCEAuthorizationUrl(directLoginMethod);
const result = await authManager.getPKCEAuthorizationUrl({ directLoginMethod });
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 result = await authManager.getPKCEAuthorizationUrl({ directLoginMethod });
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 result = await authManager.getPKCEAuthorizationUrl({ directLoginMethod });
const url = new URL(result);

expect(url.searchParams.get('direct')).toEqual('facebook');
Expand All @@ -868,10 +868,11 @@ describe('AuthManager', () => {
const configWithAudience = getConfig({ audience: 'test-audience' });
const am = new AuthManager(configWithAudience);

const result = await am.getPKCEAuthorizationUrl('apple');
const result = await am.getPKCEAuthorizationUrl({ directLoginMethod: 'apple', marketingConsentStatus: MarketingConsentStatus.OptedIn });
const url = new URL(result);

expect(url.searchParams.get('direct')).toEqual('apple');
expect(url.searchParams.get('marketingConsent')).toEqual(MarketingConsentStatus.OptedIn);
expect(url.searchParams.get('audience')).toEqual('test-audience');
});
});
Expand All @@ -880,7 +881,7 @@ describe('AuthManager', () => {
it('should pass directLoginMethod to login popup', async () => {
mockSigninPopup.mockResolvedValue(mockOidcUser);

await authManager.login('anonymous-id', 'apple');
await authManager.login('anonymous-id', { directLoginMethod: 'apple' });

expect(mockSigninPopup).toHaveBeenCalledWith({
extraQueryParams: {
Expand Down Expand Up @@ -937,7 +938,7 @@ describe('AuthManager', () => {
});

it('should pass directLoginMethod to redirect login', async () => {
await authManager.loginWithRedirect('anonymous-id', 'google');
await authManager.loginWithRedirect('anonymous-id', { directLoginMethod: 'google' });

expect(mockSigninRedirect).toHaveBeenCalledWith({
extraQueryParams: {
Expand Down
59 changes: 49 additions & 10 deletions packages/passport/sdk/src/authManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,19 +177,38 @@ export default class AuthManager {
});
};

private buildExtraQueryParams(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Record<string, string> {
return {
private buildExtraQueryParams(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Record<string, string> {
const params: Record<string, string> = {
...(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.directLoginMethod === 'email') {
const emailValue = directLoginOptions.email;
if (emailValue) {
params.direct = directLoginOptions.directLoginMethod;
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.directLoginMethod;
}
if (directLoginOptions.marketingConsentStatus) {
params.marketingConsent = directLoginOptions.marketingConsentStatus;
}
}

return params;
}

public async loginWithRedirect(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Promise<void> {
public async loginWithRedirect(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise<void> {
await this.userManager.clearStaleState();
return withPassportError<void>(async () => {
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginMethod);
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions);

await this.userManager.signinRedirect({
extraQueryParams,
Expand All @@ -200,12 +219,16 @@ 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.
* @param directLoginOptions If provided, contains login method and marketing consent options
* @param directLoginOptions.directLoginMethod The login method to use (e.g., 'google', 'apple', 'email')
* @param directLoginOptions.marketingConsentStatus Marketing consent status ('opted_in' or 'unsubscribed')
* @param directLoginOptions.email Required when directLoginMethod is 'email'
*/
public async login(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Promise<User> {
public async login(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise<User> {
return withPassportError<User>(async () => {
const popupWindowTarget = 'passportLoginPrompt';
const signinPopup = async () => {
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginMethod);
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions);

return this.userManager.signinPopup({
extraQueryParams,
Expand Down Expand Up @@ -289,7 +312,7 @@ export default class AuthManager {
}, PassportErrorType.AUTHENTICATION_ERROR);
}

public async getPKCEAuthorizationUrl(directLoginMethod?: DirectLoginMethod): Promise<string> {
public async getPKCEAuthorizationUrl(directLoginOptions?: DirectLoginOptions): Promise<string> {
const verifier = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64URLEncode(await sha256(verifier));

Expand All @@ -312,7 +335,23 @@ 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.directLoginMethod === 'email') {
const emailValue = directLoginOptions.email;
if (emailValue) {
pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod);
pKCEAuthorizationUrl.searchParams.set('email', emailValue);
}
} else {
// For non-email methods (social login), always include direct param
pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod);
}
if (directLoginOptions.marketingConsentStatus) {
pKCEAuthorizationUrl.searchParams.set('marketingConsent', directLoginOptions.marketingConsentStatus);
}
}

return pKCEAuthorizationUrl.toString();
}
Expand Down
5 changes: 5 additions & 0 deletions packages/passport/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ export type {
PassportOverrides,
PassportModuleConfiguration,
DeviceTokenResponse,
DirectLoginOptions,
DirectLoginMethod,
} from './types';
export {
MarketingConsentStatus,
} from './types';
13 changes: 13 additions & 0 deletions packages/passport/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,16 @@ export type LinkedWallet = {
name?: string;
clientName: string;
};

export enum MarketingConsentStatus {
OptedIn = 'opted_in',
Unsubscribed = 'unsubscribed',
}

export type DirectLoginOptions = {
directLoginMethod: DirectLoginMethod;
marketingConsentStatus?: MarketingConsentStatus;
} & (
| { directLoginMethod: 'email'; email: string }
| { directLoginMethod: Exclude<DirectLoginMethod, 'email'>; email?: never }
);