Skip to content

Commit 262f265

Browse files
authored
feat(passport): Passport SDK marketing consent (#2676)
1 parent 6b65780 commit 262f265

File tree

7 files changed

+122
-34
lines changed

7 files changed

+122
-34
lines changed

packages/game-bridge/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,8 @@ window.callFunction = async (jsonData: string) => {
361361
}
362362
case PASSPORT_FUNCTIONS.getPKCEAuthUrl: {
363363
const request = data ? JSON.parse(data) : {};
364-
const directLoginMethod = request?.directLoginMethod;
365-
const url = await getPassportClient().loginWithPKCEFlow(directLoginMethod);
364+
const directLoginOptions: passport.DirectLoginOptions | undefined = request?.directLoginOptions;
365+
const url = await getPassportClient().loginWithPKCEFlow(directLoginOptions);
366366
trackDuration(moduleName, 'performedGetPkceAuthUrl', mt(markStart));
367367
callbackToGame({
368368
responseFor: fxName,

packages/passport/sdk-sample-app/src/context/PassportProvider.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React, {
33
} from 'react';
44
import { IMXProvider } from '@imtbl/x-provider';
55
import {
6-
LinkedWallet, LinkWalletParams, Provider, UserProfile,
6+
LinkedWallet, LinkWalletParams, Provider, UserProfile, MarketingConsentStatus,
77
} from '@imtbl/passport';
88
import { useImmutableProvider } from '@/context/ImmutableProvider';
99
import { useStatusProvider } from '@/context/StatusProvider';
@@ -179,7 +179,12 @@ export function PassportProvider({
179179
const popupRedirectGoogle = useCallback(async () => {
180180
try {
181181
setIsLoading(true);
182-
const userProfile = await passportClient.login({ directLoginMethod: 'google' });
182+
const userProfile = await passportClient.login({
183+
directLoginOptions: {
184+
directLoginMethod: 'google',
185+
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
186+
},
187+
});
183188
addMessage('Popup Login (Google)', userProfile);
184189
} catch (err) {
185190
addMessage('Popup Login (Google)', err);
@@ -192,7 +197,12 @@ export function PassportProvider({
192197
const popupRedirectApple = useCallback(async () => {
193198
try {
194199
setIsLoading(true);
195-
const userProfile = await passportClient.login({ directLoginMethod: 'apple' });
200+
const userProfile = await passportClient.login({
201+
directLoginOptions: {
202+
directLoginMethod: 'apple',
203+
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
204+
},
205+
});
196206
addMessage('Popup Login (Apple)', userProfile);
197207
} catch (err) {
198208
addMessage('Popup Login (Apple)', err);
@@ -205,7 +215,12 @@ export function PassportProvider({
205215
const popupRedirectFacebook = useCallback(async () => {
206216
try {
207217
setIsLoading(true);
208-
const userProfile = await passportClient.login({ directLoginMethod: 'facebook' });
218+
const userProfile = await passportClient.login({
219+
directLoginOptions: {
220+
directLoginMethod: 'facebook',
221+
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
222+
},
223+
});
209224
addMessage('Popup Login (Facebook)', userProfile);
210225
} catch (err) {
211226
addMessage('Popup Login (Facebook)', err);
@@ -220,8 +235,11 @@ export function PassportProvider({
220235
try {
221236
setIsLoading(true);
222237
const userProfile = await passportClient.login({
223-
directLoginMethod: 'google',
224238
useRedirectFlow: true,
239+
directLoginOptions: {
240+
directLoginMethod: 'google',
241+
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
242+
},
225243
});
226244
addMessage('Login (Google)', userProfile);
227245
} catch (err) {
@@ -236,8 +254,11 @@ export function PassportProvider({
236254
try {
237255
setIsLoading(true);
238256
const userProfile = await passportClient.login({
239-
directLoginMethod: 'apple',
240257
useRedirectFlow: true,
258+
directLoginOptions: {
259+
directLoginMethod: 'apple',
260+
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
261+
},
241262
});
242263
addMessage('Login (Apple)', userProfile);
243264
} catch (err) {
@@ -252,8 +273,11 @@ export function PassportProvider({
252273
try {
253274
setIsLoading(true);
254275
const userProfile = await passportClient.login({
255-
directLoginMethod: 'facebook',
256276
useRedirectFlow: true,
277+
directLoginOptions: {
278+
directLoginMethod: 'facebook',
279+
marketingConsentStatus: MarketingConsentStatus.Unsubscribed,
280+
},
257281
});
258282
addMessage('Login (Facebook)', userProfile);
259283
} catch (err) {

packages/passport/sdk/src/Passport.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import MagicAdapter from './magic/magicAdapter';
1515
import { PassportImxProviderFactory } from './starkEx';
1616
import { PassportConfiguration } from './config';
1717
import {
18+
DirectLoginOptions,
1819
DeviceTokenResponse,
19-
DirectLoginMethod,
2020
isUserImx,
2121
isUserZkEvm,
2222
LinkedWallet,
@@ -192,7 +192,10 @@ export class Passport {
192192
* @param {boolean} [options.useSilentLogin] - If true, attempts silent authentication without user interaction.
193193
* Note: This takes precedence over useCachedSession if both are true
194194
* @param {boolean} [options.useRedirectFlow] - If true, uses redirect flow instead of popup flow
195-
* @param {DirectLoginMethod} [options.directLoginMethod] - If provided, directly redirects to the specified login method
195+
* @param {DirectLoginOptions} [options.directLoginOptions] - If provided, contains login method and marketing consent options
196+
* @param {string} [options.directLoginOptions.directLoginMethod] - The login method to use (e.g., 'google', 'apple', 'email')
197+
* @param {MarketingConsentStatus} [options.directLoginOptions.marketingConsentStatus] - Marketing consent status ('opted_in' or 'unsubscribed')
198+
* @param {string} [options.directLoginOptions.email] - Required when directLoginMethod is 'email'
196199
* @returns {Promise<UserProfile | null>} A promise that resolves to the user profile if logged in, null otherwise
197200
* @throws {Error} If retrieving the cached user session fails (except for "Unknown or invalid refresh token" errors)
198201
* and useCachedSession is true
@@ -202,7 +205,7 @@ export class Passport {
202205
anonymousId?: string;
203206
useSilentLogin?: boolean;
204207
useRedirectFlow?: boolean;
205-
directLoginMethod?: DirectLoginMethod;
208+
directLoginOptions?: DirectLoginOptions;
206209
}): Promise<UserProfile | null> {
207210
// If there's already a login in progress, return that promise
208211
if (this.#loginPromise) {
@@ -230,9 +233,9 @@ export class Passport {
230233
user = await this.authManager.forceUserRefresh();
231234
} else if (!user && !useCachedSession) {
232235
if (options?.useRedirectFlow) {
233-
await this.authManager.loginWithRedirect(options?.anonymousId, options?.directLoginMethod);
236+
await this.authManager.loginWithRedirect(options?.anonymousId, options?.directLoginOptions);
234237
} else {
235-
user = await this.authManager.login(options?.anonymousId, options?.directLoginMethod);
238+
user = await this.authManager.login(options?.anonymousId, options?.directLoginOptions);
236239
}
237240
}
238241

@@ -273,11 +276,14 @@ export class Passport {
273276

274277
/**
275278
* Initiates a PKCE flow login.
276-
* @param {DirectLoginMethod} [directLoginMethod] - If provided, directly redirects to the specified login method
279+
* @param {DirectLoginOptions} [directLoginOptions] - If provided, directly redirects to the specified login method
277280
* @returns {string} The authorization URL for the PKCE flow
278281
*/
279-
public loginWithPKCEFlow(directLoginMethod?: DirectLoginMethod): Promise<string> {
280-
return withMetricsAsync(async () => await this.authManager.getPKCEAuthorizationUrl(directLoginMethod), 'loginWithPKCEFlow');
282+
public loginWithPKCEFlow(directLoginOptions?: DirectLoginOptions): Promise<string> {
283+
return withMetricsAsync(
284+
async () => await this.authManager.getPKCEAuthorizationUrl(directLoginOptions),
285+
'loginWithPKCEFlow',
286+
);
281287
}
282288

283289
/**

packages/passport/sdk/src/authManager.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PassportError, PassportErrorType } from './errors/passportError';
77
import { PassportConfiguration } from './config';
88
import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks';
99
import { isAccessTokenExpiredOrExpiring } from './utils/token';
10-
import { isUserZkEvm, PassportModuleConfiguration } from './types';
10+
import { isUserZkEvm, MarketingConsentStatus, PassportModuleConfiguration } from './types';
1111

1212
jest.mock('jwt-decode');
1313
jest.mock('oidc-client-ts', () => ({
@@ -832,23 +832,23 @@ describe('AuthManager', () => {
832832

833833
it('should include direct parameter when directLoginMethod is provided', async () => {
834834
const directLoginMethod = 'apple';
835-
const result = await authManager.getPKCEAuthorizationUrl(directLoginMethod);
835+
const result = await authManager.getPKCEAuthorizationUrl({ directLoginMethod });
836836
const url = new URL(result);
837837

838838
expect(url.searchParams.get('direct')).toEqual('apple');
839839
});
840840

841841
it('should include direct parameter for google login method', async () => {
842842
const directLoginMethod = 'google';
843-
const result = await authManager.getPKCEAuthorizationUrl(directLoginMethod);
843+
const result = await authManager.getPKCEAuthorizationUrl({ directLoginMethod });
844844
const url = new URL(result);
845845

846846
expect(url.searchParams.get('direct')).toEqual('google');
847847
});
848848

849849
it('should include direct parameter for facebook login method', async () => {
850850
const directLoginMethod = 'facebook';
851-
const result = await authManager.getPKCEAuthorizationUrl(directLoginMethod);
851+
const result = await authManager.getPKCEAuthorizationUrl({ directLoginMethod });
852852
const url = new URL(result);
853853

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

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

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

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

885886
expect(mockSigninPopup).toHaveBeenCalledWith({
886887
extraQueryParams: {
@@ -937,7 +938,7 @@ describe('AuthManager', () => {
937938
});
938939

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

942943
expect(mockSigninRedirect).toHaveBeenCalledWith({
943944
extraQueryParams: {

packages/passport/sdk/src/authManager.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import logger from './utils/logger';
1616
import { isAccessTokenExpiredOrExpiring } from './utils/token';
1717
import { PassportError, PassportErrorType, withPassportError } from './errors/passportError';
1818
import {
19-
DirectLoginMethod,
19+
DirectLoginOptions,
2020
PassportMetadata,
2121
User,
2222
DeviceTokenResponse,
@@ -177,19 +177,38 @@ export default class AuthManager {
177177
});
178178
};
179179

180-
private buildExtraQueryParams(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Record<string, string> {
181-
return {
180+
private buildExtraQueryParams(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Record<string, string> {
181+
const params: Record<string, string> = {
182182
...(this.userManager.settings?.extraQueryParams ?? {}),
183183
rid: getDetail(Detail.RUNTIME_ID) || '',
184184
third_party_a_id: anonymousId || '',
185-
...(directLoginMethod && { direct: directLoginMethod }),
186185
};
186+
187+
if (directLoginOptions) {
188+
// If method is email, only include direct login params if email is valid
189+
if (directLoginOptions.directLoginMethod === 'email') {
190+
const emailValue = directLoginOptions.email;
191+
if (emailValue) {
192+
params.direct = directLoginOptions.directLoginMethod;
193+
params.email = emailValue;
194+
}
195+
// If email method but no valid email, disregard both direct and email params
196+
} else {
197+
// For non-email methods (social login), always include direct param
198+
params.direct = directLoginOptions.directLoginMethod;
199+
}
200+
if (directLoginOptions.marketingConsentStatus) {
201+
params.marketingConsent = directLoginOptions.marketingConsentStatus;
202+
}
203+
}
204+
205+
return params;
187206
}
188207

189-
public async loginWithRedirect(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Promise<void> {
208+
public async loginWithRedirect(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise<void> {
190209
await this.userManager.clearStaleState();
191210
return withPassportError<void>(async () => {
192-
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginMethod);
211+
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions);
193212

194213
await this.userManager.signinRedirect({
195214
extraQueryParams,
@@ -200,12 +219,16 @@ export default class AuthManager {
200219
/**
201220
* login
202221
* @param anonymousId Caller can pass an anonymousId if they want to associate their user's identity with immutable's internal instrumentation.
222+
* @param directLoginOptions If provided, contains login method and marketing consent options
223+
* @param directLoginOptions.directLoginMethod The login method to use (e.g., 'google', 'apple', 'email')
224+
* @param directLoginOptions.marketingConsentStatus Marketing consent status ('opted_in' or 'unsubscribed')
225+
* @param directLoginOptions.email Required when directLoginMethod is 'email'
203226
*/
204-
public async login(anonymousId?: string, directLoginMethod?: DirectLoginMethod): Promise<User> {
227+
public async login(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise<User> {
205228
return withPassportError<User>(async () => {
206229
const popupWindowTarget = 'passportLoginPrompt';
207230
const signinPopup = async () => {
208-
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginMethod);
231+
const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions);
209232

210233
return this.userManager.signinPopup({
211234
extraQueryParams,
@@ -289,7 +312,7 @@ export default class AuthManager {
289312
}, PassportErrorType.AUTHENTICATION_ERROR);
290313
}
291314

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

@@ -312,7 +335,23 @@ export default class AuthManager {
312335

313336
if (scope) pKCEAuthorizationUrl.searchParams.set('scope', scope);
314337
if (audience) pKCEAuthorizationUrl.searchParams.set('audience', audience);
315-
if (directLoginMethod) pKCEAuthorizationUrl.searchParams.set('direct', directLoginMethod);
338+
339+
if (directLoginOptions) {
340+
// If method is email, only include direct login params if email is valid
341+
if (directLoginOptions.directLoginMethod === 'email') {
342+
const emailValue = directLoginOptions.email;
343+
if (emailValue) {
344+
pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod);
345+
pKCEAuthorizationUrl.searchParams.set('email', emailValue);
346+
}
347+
} else {
348+
// For non-email methods (social login), always include direct param
349+
pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod);
350+
}
351+
if (directLoginOptions.marketingConsentStatus) {
352+
pKCEAuthorizationUrl.searchParams.set('marketingConsent', directLoginOptions.marketingConsentStatus);
353+
}
354+
}
316355

317356
return pKCEAuthorizationUrl.toString();
318357
}

packages/passport/sdk/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,9 @@ export type {
2323
PassportOverrides,
2424
PassportModuleConfiguration,
2525
DeviceTokenResponse,
26+
DirectLoginOptions,
27+
DirectLoginMethod,
28+
} from './types';
29+
export {
30+
MarketingConsentStatus,
2631
} from './types';

packages/passport/sdk/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,16 @@ export type LinkedWallet = {
176176
name?: string;
177177
clientName: string;
178178
};
179+
180+
export enum MarketingConsentStatus {
181+
OptedIn = 'opted_in',
182+
Unsubscribed = 'unsubscribed',
183+
}
184+
185+
export type DirectLoginOptions = {
186+
directLoginMethod: DirectLoginMethod;
187+
marketingConsentStatus?: MarketingConsentStatus;
188+
} & (
189+
| { directLoginMethod: 'email'; email: string }
190+
| { directLoginMethod: Exclude<DirectLoginMethod, 'email'>; email?: never }
191+
);

0 commit comments

Comments
 (0)