@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
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
|
-
|
|
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';
|