@imtbl/auth 2.12.5-alpha.8 → 2.12.5

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
@@ -137,7 +137,7 @@ describe('Auth', () => {
137
137
  });
138
138
  });
139
139
 
140
- describe('username extraction', () => {
140
+ describe('mapOidcUserToDomainModel', () => {
141
141
  it('extracts username from id token when present', () => {
142
142
  const mockOidcUser = {
143
143
  id_token: 'token',
@@ -158,257 +158,113 @@ describe('Auth', () => {
158
158
  expect(result.profile.username).toEqual('username123');
159
159
  });
160
160
 
161
- it('maps username when creating OIDC user from device tokens', () => {
162
- const tokenResponse = {
161
+ it('extracts zkEvm chain data from passport metadata', () => {
162
+ const mockOidcUser = {
163
163
  id_token: 'token',
164
164
  access_token: 'access',
165
165
  refresh_token: 'refresh',
166
- token_type: 'Bearer',
167
- expires_in: 3600,
166
+ expired: false,
167
+ profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
168
168
  };
169
169
 
170
170
  (decodeJwtPayload as jest.Mock).mockReturnValue({
171
- sub: 'user-123',
172
- iss: 'issuer',
173
- aud: 'audience',
174
- exp: 1,
175
- iat: 0,
176
- email: 'test@example.com',
177
- nickname: 'tester',
178
- username: 'username123',
179
- passport: undefined,
171
+ passport: {
172
+ zkevm_eth_address: '0xzkevmaddress',
173
+ zkevm_user_admin_address: '0xzkevmadmin',
174
+ },
180
175
  });
181
176
 
182
- const oidcUser = (Auth as any).mapDeviceTokenResponseToOidcUser(tokenResponse);
177
+ const result = (Auth as any).mapOidcUserToDomainModel(mockOidcUser);
183
178
 
184
- expect(decodeJwtPayload).toHaveBeenCalledWith('token');
185
- expect(oidcUser.profile.username).toEqual('username123');
179
+ expect(result.zkEvm).toEqual({
180
+ ethAddress: '0xzkevmaddress',
181
+ userAdminAddress: '0xzkevmadmin',
182
+ });
186
183
  });
187
- });
188
184
 
189
- describe('refreshTokenAndUpdatePromise', () => {
190
- it('emits TOKEN_REFRESHED event when signinSilent succeeds', async () => {
185
+ it('extracts arbitrum_one chain data from nested passport metadata', () => {
191
186
  const mockOidcUser = {
192
- id_token: 'new-id',
193
- access_token: 'new-access',
194
- refresh_token: 'new-refresh',
187
+ id_token: 'token',
188
+ access_token: 'access',
189
+ refresh_token: 'refresh',
195
190
  expired: false,
196
191
  profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
197
192
  };
198
193
 
199
194
  (decodeJwtPayload as jest.Mock).mockReturnValue({
200
- username: undefined,
201
- passport: undefined,
195
+ passport: {
196
+ arbitrum_one: {
197
+ eth_address: '0xarbaddress',
198
+ user_admin_address: '0xarbadmin',
199
+ },
200
+ },
202
201
  });
203
202
 
204
- const auth = Object.create(Auth.prototype) as Auth;
205
- const mockEventEmitter = { emit: jest.fn() };
206
- const mockUserManager = {
207
- signinSilent: jest.fn().mockResolvedValue(mockOidcUser),
208
- };
209
-
210
- (auth as any).eventEmitter = mockEventEmitter;
211
- (auth as any).userManager = mockUserManager;
212
- (auth as any).refreshingPromise = null;
213
-
214
- const user = await (auth as any).refreshTokenAndUpdatePromise();
215
-
216
- expect(user).toBeDefined();
217
- expect(user.accessToken).toBe('new-access');
218
- expect(mockEventEmitter.emit).toHaveBeenCalledWith(
219
- AuthEvents.TOKEN_REFRESHED,
220
- expect.objectContaining({
221
- accessToken: 'new-access',
222
- refreshToken: 'new-refresh',
223
- }),
224
- );
225
- });
226
-
227
- it('does not emit TOKEN_REFRESHED event when signinSilent returns null', async () => {
228
- const auth = Object.create(Auth.prototype) as Auth;
229
- const mockEventEmitter = { emit: jest.fn() };
230
- const mockUserManager = {
231
- signinSilent: jest.fn().mockResolvedValue(null),
232
- };
233
-
234
- (auth as any).eventEmitter = mockEventEmitter;
235
- (auth as any).userManager = mockUserManager;
236
- (auth as any).refreshingPromise = null;
237
-
238
- const user = await (auth as any).refreshTokenAndUpdatePromise();
239
-
240
- expect(user).toBeNull();
241
- expect(mockEventEmitter.emit).not.toHaveBeenCalled();
242
- });
243
-
244
- it('emits USER_REMOVED event ONLY for invalid_grant error (refresh token invalid)', async () => {
245
- const auth = Object.create(Auth.prototype) as Auth;
246
- const mockEventEmitter = { emit: jest.fn() };
247
- const mockUserManager = {
248
- signinSilent: jest.fn().mockRejectedValue(
249
- Object.assign(new Error('invalid_grant'), {
250
- error: 'invalid_grant',
251
- error_description: 'Unknown or invalid refresh token',
252
- }),
253
- ),
254
- removeUser: jest.fn().mockResolvedValue(undefined),
255
- };
203
+ const result = (Auth as any).mapOidcUserToDomainModel(mockOidcUser);
256
204
 
257
- // Make the error an instance of ErrorResponse
258
- const { ErrorResponse } = jest.requireActual('oidc-client-ts');
259
- const errorResponse = new ErrorResponse({
260
- error: 'invalid_grant',
261
- error_description: 'Unknown or invalid refresh token',
205
+ expect(result.arbitrum_one).toEqual({
206
+ ethAddress: '0xarbaddress',
207
+ userAdminAddress: '0xarbadmin',
262
208
  });
263
- mockUserManager.signinSilent.mockRejectedValue(errorResponse);
264
-
265
- (auth as any).eventEmitter = mockEventEmitter;
266
- (auth as any).userManager = mockUserManager;
267
- (auth as any).refreshingPromise = null;
268
-
269
- await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
270
-
271
- expect(mockEventEmitter.emit).toHaveBeenCalledWith(
272
- AuthEvents.USER_REMOVED,
273
- expect.objectContaining({
274
- reason: 'refresh_token_invalid',
275
- }),
276
- );
277
- expect(mockUserManager.removeUser).toHaveBeenCalled();
278
209
  });
279
210
 
280
- it('emits USER_REMOVED event for login_required error (permanent)', async () => {
281
- const auth = Object.create(Auth.prototype) as Auth;
282
- const mockEventEmitter = { emit: jest.fn() };
283
- const mockUserManager = {
284
- signinSilent: jest.fn(),
285
- removeUser: jest.fn().mockResolvedValue(undefined),
211
+ it('extracts both zkEvm and arbitrum_one when present', () => {
212
+ const mockOidcUser = {
213
+ id_token: 'token',
214
+ access_token: 'access',
215
+ refresh_token: 'refresh',
216
+ expired: false,
217
+ profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
286
218
  };
287
219
 
288
- const { ErrorResponse } = jest.requireActual('oidc-client-ts');
289
- const errorResponse = new ErrorResponse({
290
- error: 'login_required',
291
- error_description: 'User must re-authenticate',
220
+ (decodeJwtPayload as jest.Mock).mockReturnValue({
221
+ passport: {
222
+ zkevm_eth_address: '0xzkevmaddress',
223
+ zkevm_user_admin_address: '0xzkevmadmin',
224
+ arbitrum_one: {
225
+ eth_address: '0xarbaddress',
226
+ user_admin_address: '0xarbadmin',
227
+ },
228
+ },
292
229
  });
293
- mockUserManager.signinSilent.mockRejectedValue(errorResponse);
294
-
295
- (auth as any).eventEmitter = mockEventEmitter;
296
- (auth as any).userManager = mockUserManager;
297
- (auth as any).refreshingPromise = null;
298
-
299
- await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
300
-
301
- // login_required is a permanent error - should remove user
302
- expect(mockEventEmitter.emit).toHaveBeenCalledWith(
303
- AuthEvents.USER_REMOVED,
304
- expect.objectContaining({
305
- reason: 'refresh_token_invalid',
306
- }),
307
- );
308
- expect(mockUserManager.removeUser).toHaveBeenCalled();
309
- });
310
-
311
- it('does not emit USER_REMOVED event for network errors (transient)', async () => {
312
- const auth = Object.create(Auth.prototype) as Auth;
313
- const mockEventEmitter = { emit: jest.fn() };
314
- const mockUserManager = {
315
- signinSilent: jest.fn().mockRejectedValue(new Error('Network error: Failed to fetch')),
316
- removeUser: jest.fn().mockResolvedValue(undefined),
317
- };
318
-
319
- (auth as any).eventEmitter = mockEventEmitter;
320
- (auth as any).userManager = mockUserManager;
321
- (auth as any).refreshingPromise = null;
322
-
323
- await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
324
230
 
325
- // Network errors are transient - should NOT remove user or emit USER_REMOVED
326
- expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
327
- AuthEvents.USER_REMOVED,
328
- expect.anything(),
329
- );
330
- expect(mockUserManager.removeUser).not.toHaveBeenCalled();
331
- });
332
-
333
- it('does not emit USER_REMOVED event for transient OAuth errors (server_error)', async () => {
334
- const auth = Object.create(Auth.prototype) as Auth;
335
- const mockEventEmitter = { emit: jest.fn() };
336
- const mockUserManager = {
337
- signinSilent: jest.fn(),
338
- removeUser: jest.fn().mockResolvedValue(undefined),
339
- };
231
+ const result = (Auth as any).mapOidcUserToDomainModel(mockOidcUser);
340
232
 
341
- // Mock ErrorResponse with a transient error (server_error)
342
- // These are temporary server issues - safe to keep user logged in
343
- const { ErrorResponse } = jest.requireActual('oidc-client-ts');
344
- const errorResponse = new ErrorResponse({
345
- error: 'server_error',
346
- error_description: 'Internal server error',
233
+ expect(result.zkEvm).toEqual({
234
+ ethAddress: '0xzkevmaddress',
235
+ userAdminAddress: '0xzkevmadmin',
236
+ });
237
+ expect(result.arbitrum_one).toEqual({
238
+ ethAddress: '0xarbaddress',
239
+ userAdminAddress: '0xarbadmin',
347
240
  });
348
- mockUserManager.signinSilent.mockRejectedValue(errorResponse);
349
-
350
- (auth as any).eventEmitter = mockEventEmitter;
351
- (auth as any).userManager = mockUserManager;
352
- (auth as any).refreshingPromise = null;
353
-
354
- await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
355
-
356
- // server_error is a transient error - should NOT remove user
357
- expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
358
- AuthEvents.USER_REMOVED,
359
- expect.anything(),
360
- );
361
- expect(mockUserManager.removeUser).not.toHaveBeenCalled();
362
- });
363
-
364
- it('emits USER_REMOVED event for unknown errors (safer default)', async () => {
365
- const auth = Object.create(Auth.prototype) as Auth;
366
- const mockEventEmitter = { emit: jest.fn() };
367
- const mockUserManager = {
368
- signinSilent: jest.fn().mockRejectedValue(new Error('Some unknown error')),
369
- removeUser: jest.fn().mockResolvedValue(undefined),
370
- };
371
-
372
- (auth as any).eventEmitter = mockEventEmitter;
373
- (auth as any).userManager = mockUserManager;
374
- (auth as any).refreshingPromise = null;
375
-
376
- await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
377
-
378
- // Unknown errors should remove user (safer default)
379
- expect(mockEventEmitter.emit).toHaveBeenCalledWith(
380
- AuthEvents.USER_REMOVED,
381
- expect.objectContaining({
382
- reason: 'refresh_failed',
383
- }),
384
- );
385
- expect(mockUserManager.removeUser).toHaveBeenCalled();
386
241
  });
387
242
 
388
- it('does not emit USER_REMOVED event for ErrorTimeout', async () => {
389
- const auth = Object.create(Auth.prototype) as Auth;
390
- const mockEventEmitter = { emit: jest.fn() };
391
- const mockUserManager = {
392
- signinSilent: jest.fn(),
393
- removeUser: jest.fn().mockResolvedValue(undefined),
243
+ it('maps username when creating OIDC user from device tokens', () => {
244
+ const tokenResponse = {
245
+ id_token: 'token',
246
+ access_token: 'access',
247
+ refresh_token: 'refresh',
248
+ token_type: 'Bearer',
249
+ expires_in: 3600,
394
250
  };
395
251
 
396
- // Mock ErrorTimeout
397
- const { ErrorTimeout } = jest.requireActual('oidc-client-ts');
398
- const timeoutError = new ErrorTimeout('Silent sign-in timed out');
399
- mockUserManager.signinSilent.mockRejectedValue(timeoutError);
400
-
401
- (auth as any).eventEmitter = mockEventEmitter;
402
- (auth as any).userManager = mockUserManager;
403
- (auth as any).refreshingPromise = null;
252
+ (decodeJwtPayload as jest.Mock).mockReturnValue({
253
+ sub: 'user-123',
254
+ iss: 'issuer',
255
+ aud: 'audience',
256
+ exp: 1,
257
+ iat: 0,
258
+ email: 'test@example.com',
259
+ nickname: 'tester',
260
+ username: 'username123',
261
+ passport: undefined,
262
+ });
404
263
 
405
- await expect((auth as any).refreshTokenAndUpdatePromise()).rejects.toThrow();
264
+ const oidcUser = (Auth as any).mapDeviceTokenResponseToOidcUser(tokenResponse);
406
265
 
407
- expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(
408
- AuthEvents.USER_REMOVED,
409
- expect.anything(),
410
- );
411
- expect(mockUserManager.removeUser).not.toHaveBeenCalled();
266
+ expect(decodeJwtPayload).toHaveBeenCalledWith('token');
267
+ expect(oidcUser.profile.username).toEqual('username123');
412
268
  });
413
269
  });
414
270
 
package/src/Auth.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  PassportMetadata,
30
30
  IdTokenPayload,
31
31
  isUserZkEvm,
32
+ EvmChain,
32
33
  } from './types';
33
34
  import EmbeddedLoginPrompt from './login/embeddedLoginPrompt';
34
35
  import TypedEventEmitter from './utils/typedEventEmitter';
@@ -570,12 +571,25 @@ export class Auth {
570
571
  username,
571
572
  },
572
573
  };
574
+
573
575
  if (passport?.zkevm_eth_address && passport?.zkevm_user_admin_address) {
574
576
  user.zkEvm = {
575
577
  ethAddress: passport.zkevm_eth_address,
576
578
  userAdminAddress: passport.zkevm_user_admin_address,
577
579
  };
578
580
  }
581
+
582
+ const chains = Object.values(EvmChain).filter((chain) => chain !== EvmChain.ZKEVM);
583
+ for (const chain of chains) {
584
+ const chainMetadata = passport?.[chain as Exclude<EvmChain, EvmChain.ZKEVM>];
585
+ if (chainMetadata?.eth_address && chainMetadata?.user_admin_address) {
586
+ user[chain] = {
587
+ ethAddress: chainMetadata.eth_address,
588
+ userAdminAddress: chainMetadata.user_admin_address,
589
+ };
590
+ }
591
+ }
592
+
579
593
  return user;
580
594
  };
581
595
 
@@ -765,71 +779,29 @@ export class Auth {
765
779
  try {
766
780
  const newOidcUser = await this.userManager.signinSilent();
767
781
  if (newOidcUser) {
768
- const user = Auth.mapOidcUserToDomainModel(newOidcUser);
769
- // Emit TOKEN_REFRESHED event so consumers (e.g., auth-nextjs) can sync
770
- // the new tokens to their session. This is critical for refresh token
771
- // rotation - without this, the server-side session may have stale tokens.
772
- this.eventEmitter.emit(AuthEvents.TOKEN_REFRESHED, user);
773
- resolve(user);
782
+ resolve(Auth.mapOidcUserToDomainModel(newOidcUser));
774
783
  return;
775
784
  }
776
785
  resolve(null);
777
786
  } catch (err) {
778
787
  let passportErrorType = PassportErrorType.AUTHENTICATION_ERROR;
779
788
  let errorMessage = 'Failed to refresh token';
780
- // Default to REMOVING user - safer to log out on unknown errors
781
- // Only keep user logged in for explicitly known transient errors
782
789
  let removeUser = true;
783
- let removeReason: 'refresh_token_invalid' | 'refresh_failed' | 'unknown' = 'unknown';
784
790
 
785
791
  if (err instanceof ErrorTimeout) {
786
- // Timeout is transient - safe to keep user logged in
787
- // Note: removeReason is set but never used since removeUser=false
788
792
  passportErrorType = PassportErrorType.SILENT_LOGIN_ERROR;
789
793
  errorMessage = `${errorMessage}: ${err.message}`;
790
794
  removeUser = false;
791
795
  } else if (err instanceof ErrorResponse) {
792
796
  passportErrorType = PassportErrorType.NOT_LOGGED_IN_ERROR;
793
797
  errorMessage = `${errorMessage}: ${err.message || err.error_description}`;
794
- // Check for known transient OAuth errors - safe to keep user logged in
795
- // - server_error: auth server temporary issue
796
- // - temporarily_unavailable: auth server overloaded
797
- const transientErrors = ['server_error', 'temporarily_unavailable'];
798
- if (err.error && transientErrors.includes(err.error)) {
799
- removeUser = false;
800
- removeReason = 'refresh_failed';
801
- } else {
802
- // All other OAuth errors (invalid_grant, login_required, etc.) are permanent
803
- removeReason = 'refresh_token_invalid';
804
- }
805
798
  } else if (err instanceof Error) {
806
799
  errorMessage = `${errorMessage}: ${err.message}`;
807
- // Network/fetch errors are transient - safe to keep user logged in
808
- const isNetworkError = err.message.toLowerCase().includes('network')
809
- || err.message.toLowerCase().includes('fetch')
810
- || err.message.toLowerCase().includes('failed to fetch')
811
- || err.message.toLowerCase().includes('networkerror');
812
- if (isNetworkError) {
813
- // Note: removeReason is not set since removeUser=false (event won't be emitted)
814
- removeUser = false;
815
- } else {
816
- // Unknown errors - safer to remove user
817
- removeReason = 'refresh_failed';
818
- }
819
800
  } else if (typeof err === 'string') {
820
801
  errorMessage = `${errorMessage}: ${err}`;
821
- // Unknown string error - safer to remove user
822
- removeReason = 'refresh_failed';
823
802
  }
824
803
 
825
804
  if (removeUser) {
826
- // Emit USER_REMOVED event BEFORE removing user so consumers can react
827
- // (e.g., auth-nextjs can clear the NextAuth session)
828
- this.eventEmitter.emit(AuthEvents.USER_REMOVED, {
829
- reason: removeReason,
830
- error: errorMessage,
831
- });
832
-
833
805
  try {
834
806
  await this.userManager.removeUser();
835
807
  } catch (removeUserError) {
package/src/index.ts CHANGED
@@ -17,13 +17,18 @@ export type {
17
17
  AuthModuleConfiguration,
18
18
  PopupOverlayOptions,
19
19
  PassportMetadata,
20
+ PassportChainMetadata,
21
+ ChainAddress,
20
22
  IdTokenPayload,
21
23
  PKCEData,
22
24
  AuthEventMap,
23
- UserRemovedReason,
24
25
  } from './types';
25
26
  export {
26
- isUserZkEvm, RollupType, MarketingConsentStatus, AuthEvents,
27
+ isUserZkEvm,
28
+ RollupType,
29
+ EvmChain,
30
+ MarketingConsentStatus,
31
+ AuthEvents,
27
32
  } from './types';
28
33
 
29
34
  // Export TypedEventEmitter
package/src/types.ts CHANGED
@@ -16,22 +16,45 @@ export enum RollupType {
16
16
  ZKEVM = 'zkEvm',
17
17
  }
18
18
 
19
+ /**
20
+ * Supported EVM chains for user registration
21
+ * Matches EvmChain from @imtbl/wallet but defined here to avoid circular dependency
22
+ */
23
+ export enum EvmChain {
24
+ ZKEVM = 'zkevm',
25
+ ARBITRUM_ONE = 'arbitrum_one',
26
+ }
27
+
28
+ export type ChainAddress = {
29
+ ethAddress: string;
30
+ userAdminAddress: string;
31
+ };
32
+
19
33
  export type User = {
20
34
  idToken?: string;
21
35
  accessToken: string;
22
36
  refreshToken?: string;
23
37
  profile: UserProfile;
24
38
  expired?: boolean;
25
- [RollupType.ZKEVM]?: {
26
- ethAddress: string;
27
- userAdminAddress: string;
28
- };
39
+ [RollupType.ZKEVM]?: ChainAddress;
40
+ } & {
41
+ [K in Exclude<EvmChain, EvmChain.ZKEVM>]?: ChainAddress;
42
+ };
43
+
44
+ export type PassportChainMetadata = {
45
+ eth_address: string;
46
+ user_admin_address: string;
29
47
  };
30
48
 
49
+ /**
50
+ * Passport metadata
51
+ * - zkEVM: flat fields (zkevm_eth_address, zkevm_user_admin_address)
52
+ * - Other chains: nested objects (arbitrum_one: { eth_address, user_admin_address })
53
+ */
31
54
  export type PassportMetadata = {
32
55
  zkevm_eth_address?: string;
33
56
  zkevm_user_admin_address?: string;
34
- };
57
+ } & Partial<Record<Exclude<EvmChain, EvmChain.ZKEVM>, PassportChainMetadata>>;
35
58
 
36
59
  export interface OidcConfiguration {
37
60
  clientId: string;
@@ -140,44 +163,12 @@ export type LoginOptions = {
140
163
  export enum AuthEvents {
141
164
  LOGGED_OUT = 'loggedOut',
142
165
  LOGGED_IN = 'loggedIn',
143
- /**
144
- * Emitted when tokens are refreshed via signinSilent().
145
- * This is critical for refresh token rotation - when client-side refresh happens,
146
- * the new tokens must be synced to server-side session to prevent race conditions.
147
- */
148
- TOKEN_REFRESHED = 'tokenRefreshed',
149
- /**
150
- * Emitted when the user is removed from local storage due to a permanent auth error.
151
- * Only emitted for errors where the refresh token is truly invalid:
152
- * - invalid_grant: refresh token expired, revoked, or already used
153
- * - login_required: user must re-authenticate
154
- * - consent_required / interaction_required: user must interact with auth server
155
- *
156
- * NOT emitted for transient errors (network, timeout, server errors) - user stays logged in.
157
- * Consumers should sync this state by clearing their session (e.g., NextAuth signOut).
158
- */
159
- USER_REMOVED = 'userRemoved',
160
166
  }
161
167
 
162
- /**
163
- * Error reason for USER_REMOVED event.
164
- * Note: Network/timeout errors do NOT emit USER_REMOVED (user stays logged in),
165
- * so 'network_error' is not a valid reason.
166
- */
167
- export type UserRemovedReason =
168
- // OAuth permanent errors (invalid_grant, login_required, etc.)
169
- | 'refresh_token_invalid'
170
- // Unknown non-OAuth errors
171
- | 'refresh_failed'
172
- // Fallback for truly unknown error types
173
- | 'unknown';
174
-
175
168
  /**
176
169
  * Event map for typed event emitter
177
170
  */
178
171
  export interface AuthEventMap extends Record<string, any> {
179
172
  [AuthEvents.LOGGED_OUT]: [];
180
173
  [AuthEvents.LOGGED_IN]: [User];
181
- [AuthEvents.TOKEN_REFRESHED]: [User];
182
- [AuthEvents.USER_REMOVED]: [{ reason: UserRemovedReason; error?: string }];
183
174
  }