@imtbl/auth 2.12.5 → 2.12.6-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -0
- package/dist/browser/index.js +79 -27
- package/dist/node/index.cjs +96 -40
- package/dist/node/index.js +79 -27
- package/dist/types/index.d.ts +2 -1
- package/dist/types/login/standalone.d.ts +141 -0
- package/dist/types/types.d.ts +32 -3
- package/package.json +6 -6
- package/src/Auth.test.ts +225 -0
- package/src/Auth.ts +23 -10
- package/src/index.ts +16 -0
- package/src/login/standalone.ts +745 -0
- package/src/types.ts +36 -2
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone login functions for stateless authentication flows.
|
|
3
|
+
* These functions handle OAuth login without managing session state,
|
|
4
|
+
* making them ideal for use with external session managers like NextAuth.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Detail, getDetail, track } from '@imtbl/metrics';
|
|
8
|
+
import { decodeJwtPayload } from '../utils/jwt';
|
|
9
|
+
import type {
|
|
10
|
+
DirectLoginOptions, IdTokenPayload, MarketingConsentStatus, ZkEvmInfo,
|
|
11
|
+
} from '../types';
|
|
12
|
+
import { PASSPORT_OVERLAY_CONTENTS_ID } from '../overlay/constants';
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Configuration for standalone login functions
|
|
20
|
+
*/
|
|
21
|
+
export interface LoginConfig {
|
|
22
|
+
/** Your Immutable application client ID */
|
|
23
|
+
clientId: string;
|
|
24
|
+
/** The OAuth redirect URI for your application */
|
|
25
|
+
redirectUri: string;
|
|
26
|
+
/** Optional separate redirect URI for popup flows */
|
|
27
|
+
popupRedirectUri?: string;
|
|
28
|
+
/** OAuth audience (default: "platform_api") */
|
|
29
|
+
audience?: string;
|
|
30
|
+
/** OAuth scopes (default: "openid profile email offline_access transact") */
|
|
31
|
+
scope?: string;
|
|
32
|
+
/** Authentication domain (default: "https://auth.immutable.com") */
|
|
33
|
+
authenticationDomain?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Embedded login prompt types
|
|
37
|
+
const EMBEDDED_LOGIN_PROMPT_EVENT_TYPE = 'im_passport_embedded_login_prompt';
|
|
38
|
+
const LOGIN_PROMPT_IFRAME_ID = 'passport-embedded-login-iframe';
|
|
39
|
+
const PASSPORT_OVERLAY_ID = 'passport-overlay';
|
|
40
|
+
|
|
41
|
+
enum EmbeddedLoginPromptReceiveMessage {
|
|
42
|
+
LOGIN_METHOD_SELECTED = 'login_method_selected',
|
|
43
|
+
LOGIN_PROMPT_ERROR = 'login_prompt_error',
|
|
44
|
+
LOGIN_PROMPT_CLOSED = 'login_prompt_closed',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface EmbeddedLoginPromptResult {
|
|
48
|
+
marketingConsentStatus: MarketingConsentStatus;
|
|
49
|
+
imPassportTraceId: string;
|
|
50
|
+
directLoginMethod: string;
|
|
51
|
+
email?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Token response from successful authentication
|
|
56
|
+
*/
|
|
57
|
+
export interface TokenResponse {
|
|
58
|
+
/** OAuth access token for API calls */
|
|
59
|
+
accessToken: string;
|
|
60
|
+
/** OAuth refresh token for token renewal */
|
|
61
|
+
refreshToken?: string;
|
|
62
|
+
/** OpenID Connect ID token */
|
|
63
|
+
idToken?: string;
|
|
64
|
+
/** Unix timestamp (ms) when the access token expires */
|
|
65
|
+
accessTokenExpires: number;
|
|
66
|
+
/** User profile information */
|
|
67
|
+
profile: {
|
|
68
|
+
sub: string;
|
|
69
|
+
email?: string;
|
|
70
|
+
nickname?: string;
|
|
71
|
+
};
|
|
72
|
+
/** zkEVM wallet information if available */
|
|
73
|
+
zkEvm?: ZkEvmInfo;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extended login options for popup/redirect flows
|
|
78
|
+
*/
|
|
79
|
+
export interface StandaloneLoginOptions {
|
|
80
|
+
/** Direct login options (social provider, email, etc.) */
|
|
81
|
+
directLoginOptions?: DirectLoginOptions;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Constants
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
const DEFAULT_AUTH_DOMAIN = 'https://auth.immutable.com';
|
|
89
|
+
const DEFAULT_AUDIENCE = 'platform_api';
|
|
90
|
+
const DEFAULT_SCOPE = 'openid profile email offline_access transact';
|
|
91
|
+
const AUTHORIZE_ENDPOINT = '/authorize';
|
|
92
|
+
const TOKEN_ENDPOINT = '/oauth/token';
|
|
93
|
+
|
|
94
|
+
// Storage key for PKCE data
|
|
95
|
+
const PKCE_STORAGE_KEY = 'imtbl_pkce_data';
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Utility Functions
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
function base64URLEncode(buffer: ArrayBuffer | Uint8Array): string {
|
|
102
|
+
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
|
103
|
+
.replace(/\+/g, '-')
|
|
104
|
+
.replace(/\//g, '_')
|
|
105
|
+
.replace(/=/g, '');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function sha256(value: string): Promise<ArrayBuffer> {
|
|
109
|
+
const encoder = new TextEncoder();
|
|
110
|
+
const data = encoder.encode(value);
|
|
111
|
+
return window.crypto.subtle.digest('SHA-256', data);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function generateRandomString(): string {
|
|
115
|
+
return base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getAuthDomain(config: LoginConfig): string {
|
|
119
|
+
return config.authenticationDomain || DEFAULT_AUTH_DOMAIN;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getTokenExpiry(accessToken: string): number {
|
|
123
|
+
try {
|
|
124
|
+
const payload = decodeJwtPayload<{ exp?: number }>(accessToken);
|
|
125
|
+
if (payload.exp) {
|
|
126
|
+
return payload.exp * 1000; // Convert to milliseconds
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Fall back to 1 hour from now if we can't decode
|
|
130
|
+
}
|
|
131
|
+
return Date.now() + 3600 * 1000;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function mapTokenResponseToResult(
|
|
135
|
+
tokenData: {
|
|
136
|
+
access_token: string;
|
|
137
|
+
refresh_token?: string;
|
|
138
|
+
id_token?: string;
|
|
139
|
+
},
|
|
140
|
+
): TokenResponse {
|
|
141
|
+
const { access_token: accessToken, refresh_token: refreshToken, id_token: idToken } = tokenData;
|
|
142
|
+
|
|
143
|
+
let profile: TokenResponse['profile'] = { sub: '' };
|
|
144
|
+
let zkEvm: TokenResponse['zkEvm'] | undefined;
|
|
145
|
+
|
|
146
|
+
if (idToken) {
|
|
147
|
+
try {
|
|
148
|
+
const {
|
|
149
|
+
sub, email, nickname, passport,
|
|
150
|
+
} = decodeJwtPayload<IdTokenPayload>(idToken);
|
|
151
|
+
profile = { sub, email, nickname };
|
|
152
|
+
|
|
153
|
+
if (passport?.zkevm_eth_address && passport?.zkevm_user_admin_address) {
|
|
154
|
+
zkEvm = {
|
|
155
|
+
ethAddress: passport.zkevm_eth_address as `0x${string}`,
|
|
156
|
+
userAdminAddress: passport.zkevm_user_admin_address as `0x${string}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// If we can't decode the ID token, we'll have minimal profile info
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
accessToken,
|
|
166
|
+
refreshToken,
|
|
167
|
+
idToken,
|
|
168
|
+
accessTokenExpires: getTokenExpiry(accessToken),
|
|
169
|
+
profile,
|
|
170
|
+
zkEvm,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// PKCE Storage (session-only, not persisted)
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
interface PKCEData {
|
|
179
|
+
state: string;
|
|
180
|
+
verifier: string;
|
|
181
|
+
redirectUri: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function savePKCEData(data: PKCEData): void {
|
|
185
|
+
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
186
|
+
window.sessionStorage.setItem(PKCE_STORAGE_KEY, JSON.stringify(data));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getPKCEData(): PKCEData | null {
|
|
191
|
+
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
192
|
+
const data = window.sessionStorage.getItem(PKCE_STORAGE_KEY);
|
|
193
|
+
if (data) {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(data) as PKCEData;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function clearPKCEData(): void {
|
|
205
|
+
if (typeof window !== 'undefined' && window.sessionStorage) {
|
|
206
|
+
window.sessionStorage.removeItem(PKCE_STORAGE_KEY);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// Embedded Login Prompt
|
|
212
|
+
// ============================================================================
|
|
213
|
+
|
|
214
|
+
function appendEmbeddedLoginPromptStyles(): void {
|
|
215
|
+
const styleId = 'passport-embedded-login-keyframes';
|
|
216
|
+
if (document.getElementById(styleId)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const style = document.createElement('style');
|
|
221
|
+
style.id = styleId;
|
|
222
|
+
style.textContent = `
|
|
223
|
+
@keyframes passportEmbeddedLoginPromptPopBounceIn {
|
|
224
|
+
0% {
|
|
225
|
+
opacity: 0.5;
|
|
226
|
+
}
|
|
227
|
+
50% {
|
|
228
|
+
opacity: 1;
|
|
229
|
+
transform: scale(1.05);
|
|
230
|
+
}
|
|
231
|
+
75% {
|
|
232
|
+
transform: scale(0.98);
|
|
233
|
+
}
|
|
234
|
+
100% {
|
|
235
|
+
opacity: 1;
|
|
236
|
+
transform: scale(1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@media (max-height: 400px) {
|
|
241
|
+
#${LOGIN_PROMPT_IFRAME_ID} {
|
|
242
|
+
width: 100% !important;
|
|
243
|
+
max-width: none !important;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@keyframes passportEmbeddedLoginPromptOverlayFadeIn {
|
|
248
|
+
from {
|
|
249
|
+
opacity: 0;
|
|
250
|
+
}
|
|
251
|
+
to {
|
|
252
|
+
opacity: 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
`;
|
|
256
|
+
|
|
257
|
+
document.head.appendChild(style);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function createEmbeddedLoginIFrame(authDomain: string, clientId: string): HTMLIFrameElement {
|
|
261
|
+
const runtimeId = getDetail(Detail.RUNTIME_ID);
|
|
262
|
+
const iframe = document.createElement('iframe');
|
|
263
|
+
iframe.id = LOGIN_PROMPT_IFRAME_ID;
|
|
264
|
+
iframe.src = `${authDomain}/im-embedded-login-prompt?client_id=${clientId}&rid=${runtimeId}`;
|
|
265
|
+
iframe.style.height = '100vh';
|
|
266
|
+
iframe.style.width = '100vw';
|
|
267
|
+
iframe.style.maxHeight = '660px';
|
|
268
|
+
iframe.style.maxWidth = '440px';
|
|
269
|
+
iframe.style.borderRadius = '16px';
|
|
270
|
+
iframe.style.border = 'none';
|
|
271
|
+
iframe.style.opacity = '0';
|
|
272
|
+
iframe.style.transform = 'scale(0.6)';
|
|
273
|
+
iframe.style.animation = 'passportEmbeddedLoginPromptPopBounceIn 1s ease forwards';
|
|
274
|
+
appendEmbeddedLoginPromptStyles();
|
|
275
|
+
return iframe;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function createOverlayElement(): HTMLDivElement {
|
|
279
|
+
const overlay = document.createElement('div');
|
|
280
|
+
overlay.id = PASSPORT_OVERLAY_ID;
|
|
281
|
+
overlay.style.cssText = `
|
|
282
|
+
position: fixed;
|
|
283
|
+
top: 0;
|
|
284
|
+
left: 0;
|
|
285
|
+
width: 100%;
|
|
286
|
+
height: 100%;
|
|
287
|
+
display: flex;
|
|
288
|
+
flex-direction: column;
|
|
289
|
+
justify-content: center;
|
|
290
|
+
align-items: center;
|
|
291
|
+
z-index: 2147483647;
|
|
292
|
+
background: rgba(247, 247, 247, 0.24);
|
|
293
|
+
animation-name: passportEmbeddedLoginPromptOverlayFadeIn;
|
|
294
|
+
animation-duration: 0.8s;
|
|
295
|
+
`;
|
|
296
|
+
|
|
297
|
+
const contents = document.createElement('div');
|
|
298
|
+
contents.id = PASSPORT_OVERLAY_CONTENTS_ID;
|
|
299
|
+
contents.style.cssText = `
|
|
300
|
+
display: flex;
|
|
301
|
+
flex-direction: column;
|
|
302
|
+
align-items: center;
|
|
303
|
+
width: 100%;
|
|
304
|
+
`;
|
|
305
|
+
|
|
306
|
+
overlay.appendChild(contents);
|
|
307
|
+
return overlay;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function removeOverlay(): void {
|
|
311
|
+
const overlay = document.getElementById(PASSPORT_OVERLAY_ID);
|
|
312
|
+
overlay?.remove();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function displayEmbeddedLoginPrompt(
|
|
316
|
+
authDomain: string,
|
|
317
|
+
clientId: string,
|
|
318
|
+
): Promise<EmbeddedLoginPromptResult> {
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
const iframe = createEmbeddedLoginIFrame(authDomain, clientId);
|
|
321
|
+
const overlay = createOverlayElement();
|
|
322
|
+
|
|
323
|
+
const messageHandler = ({ data, origin }: MessageEvent) => {
|
|
324
|
+
if (
|
|
325
|
+
origin !== authDomain
|
|
326
|
+
|| data.eventType !== EMBEDDED_LOGIN_PROMPT_EVENT_TYPE
|
|
327
|
+
) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
switch (data.messageType as EmbeddedLoginPromptReceiveMessage) {
|
|
332
|
+
case EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED: {
|
|
333
|
+
const result = data.payload as EmbeddedLoginPromptResult;
|
|
334
|
+
window.removeEventListener('message', messageHandler);
|
|
335
|
+
removeOverlay();
|
|
336
|
+
resolve(result);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
case EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_ERROR: {
|
|
340
|
+
window.removeEventListener('message', messageHandler);
|
|
341
|
+
removeOverlay();
|
|
342
|
+
reject(new Error('Error during embedded login prompt', { cause: data.payload }));
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_CLOSED: {
|
|
346
|
+
window.removeEventListener('message', messageHandler);
|
|
347
|
+
removeOverlay();
|
|
348
|
+
reject(new Error('Login closed by user'));
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
default:
|
|
352
|
+
window.removeEventListener('message', messageHandler);
|
|
353
|
+
removeOverlay();
|
|
354
|
+
reject(new Error(`Unsupported message type: ${data.messageType}`));
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Close when clicking overlay background
|
|
360
|
+
const overlayClickHandler = (e: MouseEvent) => {
|
|
361
|
+
if (e.target === overlay) {
|
|
362
|
+
window.removeEventListener('message', messageHandler);
|
|
363
|
+
overlay.removeEventListener('click', overlayClickHandler);
|
|
364
|
+
removeOverlay();
|
|
365
|
+
reject(new Error('Login closed by user'));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
window.addEventListener('message', messageHandler);
|
|
370
|
+
overlay.addEventListener('click', overlayClickHandler);
|
|
371
|
+
|
|
372
|
+
const contents = overlay.querySelector(`#${PASSPORT_OVERLAY_CONTENTS_ID}`);
|
|
373
|
+
if (contents) {
|
|
374
|
+
contents.appendChild(iframe);
|
|
375
|
+
}
|
|
376
|
+
document.body.appendChild(overlay);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Authorization URL Builder
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
async function buildAuthorizationUrl(
|
|
385
|
+
config: LoginConfig,
|
|
386
|
+
options?: StandaloneLoginOptions,
|
|
387
|
+
): Promise<{ url: string; verifier: string; state: string }> {
|
|
388
|
+
const authDomain = getAuthDomain(config);
|
|
389
|
+
const verifier = generateRandomString();
|
|
390
|
+
const challenge = base64URLEncode(await sha256(verifier));
|
|
391
|
+
const state = generateRandomString();
|
|
392
|
+
|
|
393
|
+
const url = new URL(AUTHORIZE_ENDPOINT, authDomain);
|
|
394
|
+
url.searchParams.set('response_type', 'code');
|
|
395
|
+
url.searchParams.set('code_challenge', challenge);
|
|
396
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
397
|
+
url.searchParams.set('client_id', config.clientId);
|
|
398
|
+
url.searchParams.set('redirect_uri', config.redirectUri);
|
|
399
|
+
url.searchParams.set('state', state);
|
|
400
|
+
url.searchParams.set('scope', config.scope || DEFAULT_SCOPE);
|
|
401
|
+
|
|
402
|
+
if (config.audience) {
|
|
403
|
+
url.searchParams.set('audience', config.audience);
|
|
404
|
+
} else {
|
|
405
|
+
url.searchParams.set('audience', DEFAULT_AUDIENCE);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Add direct login options if provided
|
|
409
|
+
const directLoginOptions = options?.directLoginOptions;
|
|
410
|
+
if (directLoginOptions) {
|
|
411
|
+
if (directLoginOptions.directLoginMethod === 'email') {
|
|
412
|
+
if (directLoginOptions.email) {
|
|
413
|
+
url.searchParams.set('direct', 'email');
|
|
414
|
+
url.searchParams.set('email', directLoginOptions.email);
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
url.searchParams.set('direct', directLoginOptions.directLoginMethod);
|
|
418
|
+
}
|
|
419
|
+
if (directLoginOptions.marketingConsentStatus) {
|
|
420
|
+
url.searchParams.set('marketingConsent', directLoginOptions.marketingConsentStatus);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { url: url.toString(), verifier, state };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ============================================================================
|
|
428
|
+
// Token Exchange
|
|
429
|
+
// ============================================================================
|
|
430
|
+
|
|
431
|
+
async function exchangeCodeForTokens(
|
|
432
|
+
config: LoginConfig,
|
|
433
|
+
code: string,
|
|
434
|
+
verifier: string,
|
|
435
|
+
redirectUri: string,
|
|
436
|
+
): Promise<TokenResponse> {
|
|
437
|
+
const authDomain = getAuthDomain(config);
|
|
438
|
+
const tokenUrl = `${authDomain}${TOKEN_ENDPOINT}`;
|
|
439
|
+
|
|
440
|
+
const response = await fetch(tokenUrl, {
|
|
441
|
+
method: 'POST',
|
|
442
|
+
headers: {
|
|
443
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
444
|
+
},
|
|
445
|
+
body: new URLSearchParams({
|
|
446
|
+
grant_type: 'authorization_code',
|
|
447
|
+
client_id: config.clientId,
|
|
448
|
+
code_verifier: verifier,
|
|
449
|
+
code,
|
|
450
|
+
redirect_uri: redirectUri,
|
|
451
|
+
}),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
if (!response.ok) {
|
|
455
|
+
const errorText = await response.text();
|
|
456
|
+
let errorMessage = `Token exchange failed with status ${response.status}`;
|
|
457
|
+
try {
|
|
458
|
+
const errorData = JSON.parse(errorText);
|
|
459
|
+
if (errorData.error_description) {
|
|
460
|
+
errorMessage = errorData.error_description;
|
|
461
|
+
} else if (errorData.error) {
|
|
462
|
+
errorMessage = errorData.error;
|
|
463
|
+
}
|
|
464
|
+
} catch {
|
|
465
|
+
if (errorText) {
|
|
466
|
+
errorMessage = errorText;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
throw new Error(errorMessage);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const tokenData = await response.json();
|
|
473
|
+
return mapTokenResponseToResult(tokenData);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ============================================================================
|
|
477
|
+
// Public API
|
|
478
|
+
// ============================================================================
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Login with a popup window.
|
|
482
|
+
* Opens a popup for OAuth authentication and returns tokens when complete.
|
|
483
|
+
*
|
|
484
|
+
* @param config - Login configuration
|
|
485
|
+
* @param options - Optional login options (direct login, etc.)
|
|
486
|
+
* @returns Promise resolving to token response
|
|
487
|
+
* @throws Error if popup is blocked or login fails
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* ```typescript
|
|
491
|
+
* import { loginWithPopup } from '@imtbl/auth';
|
|
492
|
+
*
|
|
493
|
+
* const tokens = await loginWithPopup({
|
|
494
|
+
* clientId: 'your-client-id',
|
|
495
|
+
* redirectUri: 'https://your-app.com/callback',
|
|
496
|
+
* });
|
|
497
|
+
* console.log(tokens.accessToken);
|
|
498
|
+
* ```
|
|
499
|
+
*/
|
|
500
|
+
export async function loginWithPopup(
|
|
501
|
+
config: LoginConfig,
|
|
502
|
+
options?: StandaloneLoginOptions,
|
|
503
|
+
): Promise<TokenResponse> {
|
|
504
|
+
track('passport', 'standaloneLoginWithPopup');
|
|
505
|
+
|
|
506
|
+
const popupRedirectUri = config.popupRedirectUri || config.redirectUri;
|
|
507
|
+
const popupConfig = { ...config, redirectUri: popupRedirectUri };
|
|
508
|
+
|
|
509
|
+
const { url, verifier, state } = await buildAuthorizationUrl(popupConfig, options);
|
|
510
|
+
|
|
511
|
+
return new Promise((resolve, reject) => {
|
|
512
|
+
// Open popup
|
|
513
|
+
const width = 500;
|
|
514
|
+
const height = 600;
|
|
515
|
+
const left = window.screenX + (window.outerWidth - width) / 2;
|
|
516
|
+
const top = window.screenY + (window.outerHeight - height) / 2;
|
|
517
|
+
|
|
518
|
+
const popup = window.open(
|
|
519
|
+
url,
|
|
520
|
+
'immutable_login',
|
|
521
|
+
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no`,
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
if (!popup) {
|
|
525
|
+
reject(new Error('Popup was blocked. Please allow popups for this site.'));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Poll for popup completion
|
|
530
|
+
const pollInterval = setInterval(() => {
|
|
531
|
+
try {
|
|
532
|
+
if (popup.closed) {
|
|
533
|
+
clearInterval(pollInterval);
|
|
534
|
+
reject(new Error('Login popup was closed'));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Check if we can access the popup URL (same origin after redirect)
|
|
539
|
+
const popupUrl = popup.location.href;
|
|
540
|
+
if (popupUrl && popupUrl.startsWith(popupRedirectUri)) {
|
|
541
|
+
clearInterval(pollInterval);
|
|
542
|
+
popup.close();
|
|
543
|
+
|
|
544
|
+
const urlParams = new URL(popupUrl);
|
|
545
|
+
const code = urlParams.searchParams.get('code');
|
|
546
|
+
const returnedState = urlParams.searchParams.get('state');
|
|
547
|
+
const error = urlParams.searchParams.get('error');
|
|
548
|
+
const errorDescription = urlParams.searchParams.get('error_description');
|
|
549
|
+
|
|
550
|
+
if (error) {
|
|
551
|
+
reject(new Error(errorDescription || error));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!code) {
|
|
556
|
+
reject(new Error('No authorization code received'));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (returnedState !== state) {
|
|
561
|
+
reject(new Error('State mismatch - possible CSRF attack'));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Exchange code for tokens
|
|
566
|
+
exchangeCodeForTokens(popupConfig, code, verifier, popupRedirectUri)
|
|
567
|
+
.then(resolve)
|
|
568
|
+
.catch(reject);
|
|
569
|
+
}
|
|
570
|
+
} catch {
|
|
571
|
+
// Cross-origin access will throw - this is expected while on auth domain
|
|
572
|
+
}
|
|
573
|
+
}, 100);
|
|
574
|
+
|
|
575
|
+
// Timeout after 5 minutes
|
|
576
|
+
setTimeout(() => {
|
|
577
|
+
clearInterval(pollInterval);
|
|
578
|
+
if (!popup.closed) {
|
|
579
|
+
popup.close();
|
|
580
|
+
}
|
|
581
|
+
reject(new Error('Login timed out'));
|
|
582
|
+
}, 5 * 60 * 1000);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Login with an embedded iframe modal.
|
|
588
|
+
* First displays a modal for the user to select their login method (email, Google, etc.),
|
|
589
|
+
* then opens a popup for OAuth authentication and returns tokens when complete.
|
|
590
|
+
*
|
|
591
|
+
* This provides a smoother user experience compared to loginWithPopup as the user
|
|
592
|
+
* can choose their login method before the OAuth popup opens.
|
|
593
|
+
*
|
|
594
|
+
* @param config - Login configuration
|
|
595
|
+
* @returns Promise resolving to token response
|
|
596
|
+
* @throws Error if modal is closed or login fails
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```typescript
|
|
600
|
+
* import { loginWithEmbedded } from '@imtbl/auth';
|
|
601
|
+
*
|
|
602
|
+
* const tokens = await loginWithEmbedded({
|
|
603
|
+
* clientId: 'your-client-id',
|
|
604
|
+
* redirectUri: 'https://your-app.com/callback',
|
|
605
|
+
* });
|
|
606
|
+
* console.log(tokens.accessToken);
|
|
607
|
+
* ```
|
|
608
|
+
*/
|
|
609
|
+
export async function loginWithEmbedded(
|
|
610
|
+
config: LoginConfig,
|
|
611
|
+
): Promise<TokenResponse> {
|
|
612
|
+
track('passport', 'standaloneLoginWithEmbedded');
|
|
613
|
+
|
|
614
|
+
const authDomain = getAuthDomain(config);
|
|
615
|
+
|
|
616
|
+
// Display the embedded login prompt modal
|
|
617
|
+
const embeddedResult = await displayEmbeddedLoginPrompt(authDomain, config.clientId);
|
|
618
|
+
|
|
619
|
+
// Build login options from the embedded prompt result
|
|
620
|
+
const loginOptions: StandaloneLoginOptions = {
|
|
621
|
+
directLoginOptions: {
|
|
622
|
+
directLoginMethod: embeddedResult.directLoginMethod,
|
|
623
|
+
marketingConsentStatus: embeddedResult.marketingConsentStatus,
|
|
624
|
+
...(embeddedResult.directLoginMethod === 'email' && embeddedResult.email
|
|
625
|
+
? { email: embeddedResult.email }
|
|
626
|
+
: {}),
|
|
627
|
+
} as DirectLoginOptions,
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// Proceed with popup login using the selected method
|
|
631
|
+
return loginWithPopup(config, loginOptions);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Login with redirect.
|
|
636
|
+
* Redirects the current page to OAuth authentication.
|
|
637
|
+
* After authentication, the user will be redirected back to your redirectUri.
|
|
638
|
+
* Use `handleLoginCallback` to complete the flow.
|
|
639
|
+
*
|
|
640
|
+
* @param config - Login configuration
|
|
641
|
+
* @param options - Optional login options (direct login, etc.)
|
|
642
|
+
*
|
|
643
|
+
* @example
|
|
644
|
+
* ```typescript
|
|
645
|
+
* import { loginWithRedirect } from '@imtbl/auth';
|
|
646
|
+
*
|
|
647
|
+
* // In your login button handler
|
|
648
|
+
* loginWithRedirect({
|
|
649
|
+
* clientId: 'your-client-id',
|
|
650
|
+
* redirectUri: 'https://your-app.com/callback',
|
|
651
|
+
* });
|
|
652
|
+
* ```
|
|
653
|
+
*/
|
|
654
|
+
export async function loginWithRedirect(
|
|
655
|
+
config: LoginConfig,
|
|
656
|
+
options?: StandaloneLoginOptions,
|
|
657
|
+
): Promise<void> {
|
|
658
|
+
track('passport', 'standaloneLoginWithRedirect');
|
|
659
|
+
|
|
660
|
+
const { url, verifier, state } = await buildAuthorizationUrl(config, options);
|
|
661
|
+
|
|
662
|
+
// Store PKCE data for callback
|
|
663
|
+
savePKCEData({
|
|
664
|
+
state,
|
|
665
|
+
verifier,
|
|
666
|
+
redirectUri: config.redirectUri,
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// Redirect to authorization URL
|
|
670
|
+
window.location.href = url;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Handle the OAuth callback after redirect-based login.
|
|
675
|
+
* Extracts the authorization code from the URL and exchanges it for tokens.
|
|
676
|
+
*
|
|
677
|
+
* @param config - Login configuration (must match what was used in loginWithRedirect)
|
|
678
|
+
* @returns Promise resolving to token response, or undefined if not a valid callback
|
|
679
|
+
*
|
|
680
|
+
* @example
|
|
681
|
+
* ```typescript
|
|
682
|
+
* // In your callback page
|
|
683
|
+
* import { handleLoginCallback } from '@imtbl/auth';
|
|
684
|
+
*
|
|
685
|
+
* const tokens = await handleLoginCallback({
|
|
686
|
+
* clientId: 'your-client-id',
|
|
687
|
+
* redirectUri: 'https://your-app.com/callback',
|
|
688
|
+
* });
|
|
689
|
+
*
|
|
690
|
+
* if (tokens) {
|
|
691
|
+
* // Login successful, tokens contains accessToken, refreshToken, etc.
|
|
692
|
+
* await signIn('immutable', { tokens: JSON.stringify(tokens) });
|
|
693
|
+
* }
|
|
694
|
+
* ```
|
|
695
|
+
*/
|
|
696
|
+
export async function handleLoginCallback(
|
|
697
|
+
config: LoginConfig,
|
|
698
|
+
): Promise<TokenResponse | undefined> {
|
|
699
|
+
track('passport', 'standaloneHandleCallback');
|
|
700
|
+
|
|
701
|
+
if (typeof window === 'undefined') {
|
|
702
|
+
return undefined;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
706
|
+
const code = urlParams.get('code');
|
|
707
|
+
const returnedState = urlParams.get('state');
|
|
708
|
+
const error = urlParams.get('error');
|
|
709
|
+
const errorDescription = urlParams.get('error_description');
|
|
710
|
+
|
|
711
|
+
// Check for OAuth error
|
|
712
|
+
if (error) {
|
|
713
|
+
throw new Error(errorDescription || error);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// No code means this isn't a callback
|
|
717
|
+
if (!code) {
|
|
718
|
+
return undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Get stored PKCE data
|
|
722
|
+
const pkceData = getPKCEData();
|
|
723
|
+
if (!pkceData) {
|
|
724
|
+
throw new Error('No PKCE data found. Login may have been initiated in a different session.');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Validate state
|
|
728
|
+
if (returnedState !== pkceData.state) {
|
|
729
|
+
clearPKCEData();
|
|
730
|
+
throw new Error('State mismatch - possible CSRF attack');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Exchange code for tokens
|
|
734
|
+
const tokens = await exchangeCodeForTokens(
|
|
735
|
+
config,
|
|
736
|
+
code,
|
|
737
|
+
pkceData.verifier,
|
|
738
|
+
pkceData.redirectUri,
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
// Clear PKCE data after successful exchange
|
|
742
|
+
clearPKCEData();
|
|
743
|
+
|
|
744
|
+
return tokens;
|
|
745
|
+
}
|