@imtbl/auth 2.12.5 → 2.12.6-alpha.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imtbl/auth",
3
- "version": "2.12.5",
3
+ "version": "2.12.6-alpha.1",
4
4
  "description": "Authentication SDK for Immutable",
5
5
  "author": "Immutable",
6
6
  "bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
@@ -25,18 +25,18 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "@imtbl/generated-clients": "2.12.5",
29
- "@imtbl/metrics": "2.12.5",
28
+ "@imtbl/generated-clients": "2.12.6-alpha.1",
29
+ "@imtbl/metrics": "2.12.6-alpha.1",
30
30
  "localforage": "^1.10.0",
31
31
  "oidc-client-ts": "3.4.1"
32
32
  },
33
33
  "devDependencies": {
34
- "@swc/core": "^1.3.36",
34
+ "@swc/core": "^1.4.2",
35
35
  "@swc/jest": "^0.2.37",
36
36
  "@types/jest": "^29.5.12",
37
- "@types/node": "^18.14.2",
37
+ "@types/node": "^22.10.7",
38
38
  "@jest/test-sequencer": "^29.7.0",
39
- "jest": "^29.4.3",
39
+ "jest": "^29.7.0",
40
40
  "jest-environment-jsdom": "^29.4.3",
41
41
  "ts-node": "^10.9.1",
42
42
  "tsup": "^8.3.0",
package/src/Auth.test.ts CHANGED
@@ -268,6 +268,231 @@ describe('Auth', () => {
268
268
  });
269
269
  });
270
270
 
271
+ describe('refreshTokenAndUpdatePromise', () => {
272
+ it('emits TOKEN_REFRESHED event when signinSilent succeeds', async () => {
273
+ const mockOidcUser = {
274
+ id_token: 'new-id',
275
+ access_token: 'new-access',
276
+ refresh_token: 'new-refresh',
277
+ expired: false,
278
+ profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
279
+ };
280
+
281
+ (decodeJwtPayload as jest.Mock).mockReturnValue({
282
+ username: undefined,
283
+ passport: undefined,
284
+ });
285
+
286
+ const auth = Object.create(Auth.prototype) as Auth;
287
+ const mockEventEmitter = { emit: jest.fn() };
288
+ const mockUserManager = {
289
+ signinSilent: jest.fn().mockResolvedValue(mockOidcUser),
290
+ };
291
+
292
+ (auth as any).eventEmitter = mockEventEmitter;
293
+ (auth as any).userManager = mockUserManager;
294
+ (auth as any).refreshingPromise = null;
295
+
296
+ const user = await (auth as any).refreshTokenAndUpdatePromise();
297
+
298
+ expect(user).toBeDefined();
299
+ expect(user.accessToken).toBe('new-access');
300
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
301
+ AuthEvents.TOKEN_REFRESHED,
302
+ expect.objectContaining({
303
+ accessToken: 'new-access',
304
+ refreshToken: 'new-refresh',
305
+ }),
306
+ );
307
+ });
308
+
309
+ it('does not emit TOKEN_REFRESHED event when signinSilent returns null', async () => {
310
+ const auth = Object.create(Auth.prototype) as Auth;
311
+ const mockEventEmitter = { emit: jest.fn() };
312
+ const mockUserManager = {
313
+ signinSilent: jest.fn().mockResolvedValue(null),
314
+ };
315
+
316
+ (auth as any).eventEmitter = mockEventEmitter;
317
+ (auth as any).userManager = mockUserManager;
318
+ (auth as any).refreshingPromise = null;
319
+
320
+ const user = await (auth as any).refreshTokenAndUpdatePromise();
321
+
322
+ expect(user).toBeNull();
323
+ expect(mockEventEmitter.emit).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('emits USER_REMOVED event for invalid_grant error', async () => {
327
+ const auth = Object.create(Auth.prototype) as Auth;
328
+ const mockEventEmitter = { emit: jest.fn() };
329
+ const mockUserManager = {
330
+ signinSilent: jest.fn().mockRejectedValue(
331
+ Object.assign(new Error('invalid_grant'), {
332
+ error: 'invalid_grant',
333
+ error_description: 'Unknown or invalid refresh token',
334
+ }),
335
+ ),
336
+ removeUser: jest.fn().mockResolvedValue(undefined),
337
+ };
338
+
339
+ // Make the error an instance of ErrorResponse
340
+ const { ErrorResponse } = jest.requireActual('oidc-client-ts');
341
+ const errorResponse = new ErrorResponse({
342
+ error: 'invalid_grant',
343
+ error_description: 'Unknown or invalid refresh token',
344
+ });
345
+ mockUserManager.signinSilent.mockRejectedValue(errorResponse);
346
+
347
+ (auth as any).eventEmitter = mockEventEmitter;
348
+ (auth as any).userManager = mockUserManager;
349
+ (auth as any).refreshingPromise = null;
350
+
351
+ await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
352
+
353
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
354
+ AuthEvents.USER_REMOVED,
355
+ expect.objectContaining({
356
+ reason: 'refresh_failed',
357
+ }),
358
+ );
359
+ expect(mockUserManager.removeUser).toHaveBeenCalled();
360
+ });
361
+
362
+ it('emits USER_REMOVED event for login_required error', async () => {
363
+ const auth = Object.create(Auth.prototype) as Auth;
364
+ const mockEventEmitter = { emit: jest.fn() };
365
+ const mockUserManager = {
366
+ signinSilent: jest.fn(),
367
+ removeUser: jest.fn().mockResolvedValue(undefined),
368
+ };
369
+
370
+ const { ErrorResponse } = jest.requireActual('oidc-client-ts');
371
+ const errorResponse = new ErrorResponse({
372
+ error: 'login_required',
373
+ error_description: 'User must re-authenticate',
374
+ });
375
+ mockUserManager.signinSilent.mockRejectedValue(errorResponse);
376
+
377
+ (auth as any).eventEmitter = mockEventEmitter;
378
+ (auth as any).userManager = mockUserManager;
379
+ (auth as any).refreshingPromise = null;
380
+
381
+ await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
382
+
383
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
384
+ AuthEvents.USER_REMOVED,
385
+ expect.objectContaining({
386
+ reason: 'refresh_failed',
387
+ }),
388
+ );
389
+ expect(mockUserManager.removeUser).toHaveBeenCalled();
390
+ });
391
+
392
+ it('emits USER_REMOVED event for network errors', async () => {
393
+ const auth = Object.create(Auth.prototype) as Auth;
394
+ const mockEventEmitter = { emit: jest.fn() };
395
+ const mockUserManager = {
396
+ signinSilent: jest.fn().mockRejectedValue(new Error('Network error: Failed to fetch')),
397
+ removeUser: jest.fn().mockResolvedValue(undefined),
398
+ };
399
+
400
+ (auth as any).eventEmitter = mockEventEmitter;
401
+ (auth as any).userManager = mockUserManager;
402
+ (auth as any).refreshingPromise = null;
403
+
404
+ await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
405
+
406
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
407
+ AuthEvents.USER_REMOVED,
408
+ expect.objectContaining({
409
+ reason: 'refresh_failed',
410
+ }),
411
+ );
412
+ expect(mockUserManager.removeUser).toHaveBeenCalled();
413
+ });
414
+
415
+ it('emits USER_REMOVED event for server_error OAuth error', async () => {
416
+ const auth = Object.create(Auth.prototype) as Auth;
417
+ const mockEventEmitter = { emit: jest.fn() };
418
+ const mockUserManager = {
419
+ signinSilent: jest.fn(),
420
+ removeUser: jest.fn().mockResolvedValue(undefined),
421
+ };
422
+
423
+ const { ErrorResponse } = jest.requireActual('oidc-client-ts');
424
+ const errorResponse = new ErrorResponse({
425
+ error: 'server_error',
426
+ error_description: 'Internal server error',
427
+ });
428
+ mockUserManager.signinSilent.mockRejectedValue(errorResponse);
429
+
430
+ (auth as any).eventEmitter = mockEventEmitter;
431
+ (auth as any).userManager = mockUserManager;
432
+ (auth as any).refreshingPromise = null;
433
+
434
+ await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
435
+
436
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
437
+ AuthEvents.USER_REMOVED,
438
+ expect.objectContaining({
439
+ reason: 'refresh_failed',
440
+ }),
441
+ );
442
+ expect(mockUserManager.removeUser).toHaveBeenCalled();
443
+ });
444
+
445
+ it('emits USER_REMOVED event for unknown errors (safer default)', async () => {
446
+ const auth = Object.create(Auth.prototype) as Auth;
447
+ const mockEventEmitter = { emit: jest.fn() };
448
+ const mockUserManager = {
449
+ signinSilent: jest.fn().mockRejectedValue(new Error('Some unknown error')),
450
+ removeUser: jest.fn().mockResolvedValue(undefined),
451
+ };
452
+
453
+ (auth as any).eventEmitter = mockEventEmitter;
454
+ (auth as any).userManager = mockUserManager;
455
+ (auth as any).refreshingPromise = null;
456
+
457
+ await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
458
+
459
+ // Unknown errors should remove user (safer default)
460
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
461
+ AuthEvents.USER_REMOVED,
462
+ expect.objectContaining({
463
+ reason: 'refresh_failed',
464
+ }),
465
+ );
466
+ expect(mockUserManager.removeUser).toHaveBeenCalled();
467
+ });
468
+
469
+ it('does not emit USER_REMOVED event for ErrorTimeout', async () => {
470
+ const auth = Object.create(Auth.prototype) as Auth;
471
+ const mockEventEmitter = { emit: jest.fn() };
472
+ const mockUserManager = {
473
+ signinSilent: jest.fn(),
474
+ removeUser: jest.fn().mockResolvedValue(undefined),
475
+ };
476
+
477
+ // Mock ErrorTimeout
478
+ const { ErrorTimeout } = jest.requireActual('oidc-client-ts');
479
+ const timeoutError = new ErrorTimeout('Silent sign-in timed out');
480
+ mockUserManager.signinSilent.mockRejectedValue(timeoutError);
481
+
482
+ (auth as any).eventEmitter = mockEventEmitter;
483
+ (auth as any).userManager = mockUserManager;
484
+ (auth as any).refreshingPromise = null;
485
+
486
+ await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
487
+
488
+ expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
489
+ AuthEvents.USER_REMOVED,
490
+ expect.anything(),
491
+ );
492
+ expect(mockUserManager.removeUser).not.toHaveBeenCalled();
493
+ });
494
+ });
495
+
271
496
  describe('loginWithPopup', () => {
272
497
  let mockUserManager: any;
273
498
  let originalCryptoRandomUUID: any;
package/src/Auth.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  IdTokenPayload,
31
31
  isUserZkEvm,
32
32
  EvmChain,
33
+ ChainAddress,
33
34
  } from './types';
34
35
  import EmbeddedLoginPrompt from './login/embeddedLoginPrompt';
35
36
  import TypedEventEmitter from './utils/typedEventEmitter';
@@ -41,6 +42,7 @@ import logger from './utils/logger';
41
42
  import { isAccessTokenExpiredOrExpiring } from './utils/token';
42
43
  import LoginPopupOverlay from './overlay/loginPopupOverlay';
43
44
  import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage';
45
+ import { buildLogoutUrl } from './logout';
44
46
 
45
47
  const formUrlEncodedHeaders = {
46
48
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -75,13 +77,12 @@ const extractTokenErrorMessage = (
75
77
  return `Token request failed with status ${status}`;
76
78
  };
77
79
 
78
- const logoutEndpoint = '/v2/logout';
79
- const crossSdkBridgeLogoutEndpoint = '/im-logged-out';
80
- const authorizeEndpoint = '/authorize';
80
+ const toChainAddress = (ethAddress: string, userAdminAddress: string): ChainAddress => ({
81
+ ethAddress: ethAddress as `0x${string}`,
82
+ userAdminAddress: userAdminAddress as `0x${string}`,
83
+ });
81
84
 
82
- const getLogoutEndpointPath = (crossSdkBridgeEnabled: boolean): string => (
83
- crossSdkBridgeEnabled ? crossSdkBridgeLogoutEndpoint : logoutEndpoint
84
- );
85
+ const authorizeEndpoint = '/authorize';
85
86
 
86
87
  const getAuthConfiguration = (config: IAuthConfiguration): UserManagerSettings => {
87
88
  const { authenticationDomain, oidcConfiguration } = config;
@@ -96,14 +97,12 @@ const getAuthConfiguration = (config: IAuthConfiguration): UserManagerSettings =
96
97
  }
97
98
  const userStore = new WebStorageStateStore({ store });
98
99
 
99
- const endSessionEndpoint = new URL(
100
- getLogoutEndpointPath(config.crossSdkBridgeEnabled),
101
- authenticationDomain.replace(/^(?:https?:\/\/)?(.*)/, 'https://$1'),
102
- );
103
- endSessionEndpoint.searchParams.set('client_id', oidcConfiguration.clientId);
104
- if (oidcConfiguration.logoutRedirectUri) {
105
- endSessionEndpoint.searchParams.set('returnTo', oidcConfiguration.logoutRedirectUri);
106
- }
100
+ const endSessionEndpoint = buildLogoutUrl({
101
+ clientId: oidcConfiguration.clientId,
102
+ authenticationDomain,
103
+ logoutRedirectUri: oidcConfiguration.logoutRedirectUri,
104
+ crossSdkBridgeEnabled: config.crossSdkBridgeEnabled,
105
+ });
107
106
 
108
107
  return {
109
108
  authority: authenticationDomain,
@@ -114,7 +113,7 @@ const getAuthConfiguration = (config: IAuthConfiguration): UserManagerSettings =
114
113
  authorization_endpoint: `${authenticationDomain}/authorize`,
115
114
  token_endpoint: `${authenticationDomain}/oauth/token`,
116
115
  userinfo_endpoint: `${authenticationDomain}/userinfo`,
117
- end_session_endpoint: endSessionEndpoint.toString(),
116
+ end_session_endpoint: endSessionEndpoint,
118
117
  revocation_endpoint: `${authenticationDomain}/oauth/revoke`,
119
118
  },
120
119
  automaticSilentRenew: false,
@@ -573,20 +572,14 @@ export class Auth {
573
572
  };
574
573
 
575
574
  if (passport?.zkevm_eth_address && passport?.zkevm_user_admin_address) {
576
- user.zkEvm = {
577
- ethAddress: passport.zkevm_eth_address,
578
- userAdminAddress: passport.zkevm_user_admin_address,
579
- };
575
+ user.zkEvm = toChainAddress(passport.zkevm_eth_address, passport.zkevm_user_admin_address);
580
576
  }
581
577
 
582
578
  const chains = Object.values(EvmChain).filter((chain) => chain !== EvmChain.ZKEVM);
583
579
  for (const chain of chains) {
584
580
  const chainMetadata = passport?.[chain as Exclude<EvmChain, EvmChain.ZKEVM>];
585
581
  if (chainMetadata?.eth_address && chainMetadata?.user_admin_address) {
586
- user[chain] = {
587
- ethAddress: chainMetadata.eth_address,
588
- userAdminAddress: chainMetadata.user_admin_address,
589
- };
582
+ user[chain] = toChainAddress(chainMetadata.eth_address, chainMetadata.user_admin_address);
590
583
  }
591
584
  }
592
585
 
@@ -779,15 +772,21 @@ export class Auth {
779
772
  try {
780
773
  const newOidcUser = await this.userManager.signinSilent();
781
774
  if (newOidcUser) {
782
- resolve(Auth.mapOidcUserToDomainModel(newOidcUser));
775
+ const user = Auth.mapOidcUserToDomainModel(newOidcUser);
776
+ // Emit TOKEN_REFRESHED event so consumers (e.g., auth-next-client) can sync
777
+ // the new tokens to their session. This is critical for refresh token
778
+ // rotation - without this, the server-side session may have stale tokens.
779
+ this.eventEmitter.emit(AuthEvents.TOKEN_REFRESHED, user);
780
+ resolve(user);
783
781
  return;
784
782
  }
785
783
  resolve(null);
786
784
  } catch (err) {
787
785
  let passportErrorType = PassportErrorType.AUTHENTICATION_ERROR;
788
786
  let errorMessage = 'Failed to refresh token';
787
+ // Default to REMOVING user - safer to log out on unknown errors
788
+ // Only keep user logged in for explicitly known transient errors
789
789
  let removeUser = true;
790
-
791
790
  if (err instanceof ErrorTimeout) {
792
791
  passportErrorType = PassportErrorType.SILENT_LOGIN_ERROR;
793
792
  errorMessage = `${errorMessage}: ${err.message}`;
@@ -802,6 +801,13 @@ export class Auth {
802
801
  }
803
802
 
804
803
  if (removeUser) {
804
+ // Emit USER_REMOVED event BEFORE removing user so consumers can react
805
+ // (e.g., auth-next-client can clear the NextAuth session)
806
+ this.eventEmitter.emit(AuthEvents.USER_REMOVED, {
807
+ reason: 'refresh_failed',
808
+ error: errorMessage,
809
+ });
810
+
805
811
  try {
806
812
  await this.userManager.removeUser();
807
813
  } catch (removeUserError) {
package/src/index.ts CHANGED
@@ -19,9 +19,11 @@ export type {
19
19
  PassportMetadata,
20
20
  PassportChainMetadata,
21
21
  ChainAddress,
22
+ ZkEvmInfo,
22
23
  IdTokenPayload,
23
24
  PKCEData,
24
25
  AuthEventMap,
26
+ UserRemovedReason,
25
27
  } from './types';
26
28
  export {
27
29
  isUserZkEvm,
@@ -40,3 +42,28 @@ export {
40
42
  } from './errors';
41
43
 
42
44
  export { decodeJwtPayload } from './utils/jwt';
45
+
46
+ // ============================================================================
47
+ // Standalone Login Functions (stateless, for use with NextAuth or similar)
48
+ // ============================================================================
49
+
50
+ export {
51
+ loginWithPopup,
52
+ loginWithEmbedded,
53
+ loginWithRedirect,
54
+ handleLoginCallback,
55
+ type LoginConfig,
56
+ type TokenResponse,
57
+ type StandaloneLoginOptions,
58
+ } from './login/standalone';
59
+
60
+ // ============================================================================
61
+ // Standalone Logout Functions (stateless, for use with NextAuth or similar)
62
+ // ============================================================================
63
+
64
+ export {
65
+ logoutWithRedirect,
66
+ logoutSilent,
67
+ buildLogoutUrl,
68
+ type LogoutConfig,
69
+ } from './login/standalone';