@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/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';
@@ -75,6 +76,11 @@ const extractTokenErrorMessage = (
75
76
  return `Token request failed with status ${status}`;
76
77
  };
77
78
 
79
+ const toChainAddress = (ethAddress: string, userAdminAddress: string): ChainAddress => ({
80
+ ethAddress: ethAddress as `0x${string}`,
81
+ userAdminAddress: userAdminAddress as `0x${string}`,
82
+ });
83
+
78
84
  const logoutEndpoint = '/v2/logout';
79
85
  const crossSdkBridgeLogoutEndpoint = '/im-logged-out';
80
86
  const authorizeEndpoint = '/authorize';
@@ -573,20 +579,14 @@ export class Auth {
573
579
  };
574
580
 
575
581
  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
- };
582
+ user.zkEvm = toChainAddress(passport.zkevm_eth_address, passport.zkevm_user_admin_address);
580
583
  }
581
584
 
582
585
  const chains = Object.values(EvmChain).filter((chain) => chain !== EvmChain.ZKEVM);
583
586
  for (const chain of chains) {
584
587
  const chainMetadata = passport?.[chain as Exclude<EvmChain, EvmChain.ZKEVM>];
585
588
  if (chainMetadata?.eth_address && chainMetadata?.user_admin_address) {
586
- user[chain] = {
587
- ethAddress: chainMetadata.eth_address,
588
- userAdminAddress: chainMetadata.user_admin_address,
589
- };
589
+ user[chain] = toChainAddress(chainMetadata.eth_address, chainMetadata.user_admin_address);
590
590
  }
591
591
  }
592
592
 
@@ -779,15 +779,21 @@ export class Auth {
779
779
  try {
780
780
  const newOidcUser = await this.userManager.signinSilent();
781
781
  if (newOidcUser) {
782
- resolve(Auth.mapOidcUserToDomainModel(newOidcUser));
782
+ const user = Auth.mapOidcUserToDomainModel(newOidcUser);
783
+ // Emit TOKEN_REFRESHED event so consumers (e.g., auth-next-client) can sync
784
+ // the new tokens to their session. This is critical for refresh token
785
+ // rotation - without this, the server-side session may have stale tokens.
786
+ this.eventEmitter.emit(AuthEvents.TOKEN_REFRESHED, user);
787
+ resolve(user);
783
788
  return;
784
789
  }
785
790
  resolve(null);
786
791
  } catch (err) {
787
792
  let passportErrorType = PassportErrorType.AUTHENTICATION_ERROR;
788
793
  let errorMessage = 'Failed to refresh token';
794
+ // Default to REMOVING user - safer to log out on unknown errors
795
+ // Only keep user logged in for explicitly known transient errors
789
796
  let removeUser = true;
790
-
791
797
  if (err instanceof ErrorTimeout) {
792
798
  passportErrorType = PassportErrorType.SILENT_LOGIN_ERROR;
793
799
  errorMessage = `${errorMessage}: ${err.message}`;
@@ -802,6 +808,13 @@ export class Auth {
802
808
  }
803
809
 
804
810
  if (removeUser) {
811
+ // Emit USER_REMOVED event BEFORE removing user so consumers can react
812
+ // (e.g., auth-next-client can clear the NextAuth session)
813
+ this.eventEmitter.emit(AuthEvents.USER_REMOVED, {
814
+ reason: 'refresh_failed',
815
+ error: errorMessage,
816
+ });
817
+
805
818
  try {
806
819
  await this.userManager.removeUser();
807
820
  } 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,17 @@ 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';