@imtbl/auth 2.10.7-alpha.6 → 2.11.1-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/src/Auth.ts CHANGED
@@ -7,8 +7,6 @@ import {
7
7
  UserManagerSettings,
8
8
  WebStorageStateStore,
9
9
  } from 'oidc-client-ts';
10
- import axios from 'axios';
11
- import jwt_decode from 'jwt-decode';
12
10
  import localForage from 'localforage';
13
11
  import {
14
12
  Detail,
@@ -35,6 +33,7 @@ import {
35
33
  import EmbeddedLoginPrompt from './login/embeddedLoginPrompt';
36
34
  import TypedEventEmitter from './utils/typedEventEmitter';
37
35
  import { withMetricsAsync } from './utils/metrics';
36
+ import { decodeJwtPayload } from './utils/jwt';
38
37
  import DeviceCredentialsManager from './storage/device_credentials_manager';
39
38
  import { PassportError, PassportErrorType, withPassportError } from './errors';
40
39
  import logger from './utils/logger';
@@ -42,12 +41,37 @@ import { isAccessTokenExpiredOrExpiring } from './utils/token';
42
41
  import LoginPopupOverlay from './overlay/loginPopupOverlay';
43
42
  import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage';
44
43
 
45
- const LOGIN_POPUP_CLOSED_POLLING_DURATION = 500;
44
+ const formUrlEncodedHeaders = {
45
+ 'Content-Type': 'application/x-www-form-urlencoded',
46
+ };
47
+
48
+ const parseJsonSafely = (text: string): unknown => {
49
+ if (!text) {
50
+ return undefined;
51
+ }
52
+ try {
53
+ return JSON.parse(text);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ };
46
58
 
47
- const formUrlEncodedHeader = {
48
- headers: {
49
- 'Content-Type': 'application/x-www-form-urlencoded',
50
- },
59
+ const extractTokenErrorMessage = (
60
+ payload: unknown,
61
+ fallbackText: string,
62
+ status: number,
63
+ ): string => {
64
+ if (payload && typeof payload === 'object') {
65
+ const data = payload as Record<string, unknown>;
66
+ const description = data.error_description ?? data.message ?? data.error;
67
+ if (typeof description === 'string' && description.trim().length > 0) {
68
+ return description;
69
+ }
70
+ }
71
+ if (fallbackText.trim().length > 0) {
72
+ return fallbackText;
73
+ }
74
+ return `Token request failed with status ${status}`;
51
75
  };
52
76
 
53
77
  const logoutEndpoint = '/v2/logout';
@@ -206,8 +230,7 @@ export class Auth {
206
230
 
207
231
  // Emit LOGGED_IN event and identify user if logged in
208
232
  if (user) {
209
- this.eventEmitter.emit(AuthEvents.LOGGED_IN, user);
210
- identify({ passportId: user.profile.sub });
233
+ this.handleSuccessfulLogin(user);
211
234
  }
212
235
 
213
236
  return user;
@@ -233,8 +256,7 @@ export class Auth {
233
256
  return withMetricsAsync(async () => {
234
257
  const user = await this.loginCallbackInternal();
235
258
  if (user) {
236
- this.eventEmitter.emit(AuthEvents.LOGGED_IN, user);
237
- identify({ passportId: user.profile.sub });
259
+ this.handleSuccessfulLogin(user);
238
260
  }
239
261
  return user;
240
262
  }, 'loginCallback');
@@ -256,7 +278,7 @@ export class Auth {
256
278
  * @returns Promise that resolves with the user or null if not authenticated
257
279
  */
258
280
  async getUser(): Promise<User | null> {
259
- return withMetricsAsync(async () => this.getUserInternal(), 'getUserInfo', false);
281
+ return this.getUserInternal();
260
282
  }
261
283
 
262
284
  /**
@@ -275,7 +297,9 @@ export class Auth {
275
297
  return user;
276
298
  }
277
299
 
278
- return this.loginWithPopup();
300
+ const loggedInUser = await this.loginWithPopup();
301
+ this.handleSuccessfulLogin(loggedInUser);
302
+ return loggedInUser;
279
303
  }
280
304
 
281
305
  /**
@@ -354,8 +378,7 @@ export class Auth {
354
378
  async loginWithPKCEFlowCallback(authorizationCode: string, state: string): Promise<User> {
355
379
  return withMetricsAsync(async () => {
356
380
  const user = await this.loginWithPKCEFlowCallbackInternal(authorizationCode, state);
357
- this.eventEmitter.emit(AuthEvents.LOGGED_IN, user);
358
- identify({ passportId: user.profile.sub });
381
+ this.handleSuccessfulLogin(user);
359
382
  return user;
360
383
  }, 'loginWithPKCEFlowCallback');
361
384
  }
@@ -368,8 +391,7 @@ export class Auth {
368
391
  async storeTokens(tokenResponse: DeviceTokenResponse): Promise<User> {
369
392
  return withMetricsAsync(async () => {
370
393
  const user = await this.storeTokensInternal(tokenResponse);
371
- this.eventEmitter.emit(AuthEvents.LOGGED_IN, user);
372
- identify({ passportId: user.profile.sub });
394
+ this.handleSuccessfulLogin(user);
373
395
  return user;
374
396
  }, 'storeTokens');
375
397
  }
@@ -413,6 +435,11 @@ export class Auth {
413
435
  return this.config.oidcConfiguration.clientId;
414
436
  }
415
437
 
438
+ private handleSuccessfulLogin(user: User): void {
439
+ this.eventEmitter.emit(AuthEvents.LOGGED_IN, user);
440
+ identify({ passportId: user.profile.sub });
441
+ }
442
+
416
443
  private buildExtraQueryParams(
417
444
  directLoginOptions?: DirectLoginOptions,
418
445
  imPassportTraceId?: string,
@@ -420,7 +447,6 @@ export class Auth {
420
447
  const params: Record<string, string> = {
421
448
  ...(this.userManager.settings?.extraQueryParams ?? {}),
422
449
  rid: getDetail(Detail.RUNTIME_ID) || '',
423
- third_party_a_id: '',
424
450
  };
425
451
 
426
452
  if (directLoginOptions) {
@@ -472,7 +498,7 @@ export class Auth {
472
498
  const signinPopup = async () => {
473
499
  const extraQueryParams = this.buildExtraQueryParams(directLoginOptionsToUse, imPassportTraceId);
474
500
 
475
- const userPromise = this.userManager.signinPopup({
501
+ return this.userManager.signinPopup({
476
502
  extraQueryParams,
477
503
  popupWindowFeatures: {
478
504
  width: 410,
@@ -480,27 +506,6 @@ export class Auth {
480
506
  },
481
507
  popupWindowTarget,
482
508
  });
483
-
484
- const popupRef = window.open('', popupWindowTarget);
485
- if (popupRef) {
486
- const popupClosedPromise = new Promise<never>((_, reject) => {
487
- const timer = setInterval(() => {
488
- if (popupRef.closed) {
489
- clearInterval(timer);
490
- reject(new Error('Popup closed by user'));
491
- }
492
- }, LOGIN_POPUP_CLOSED_POLLING_DURATION);
493
-
494
- userPromise.finally(() => {
495
- clearInterval(timer);
496
- popupRef.close();
497
- });
498
- });
499
-
500
- return Promise.race([userPromise, popupClosedPromise]);
501
- }
502
-
503
- return userPromise;
504
509
  };
505
510
 
506
511
  return new Promise((resolve, reject) => {
@@ -542,8 +547,13 @@ export class Auth {
542
547
 
543
548
  private static mapOidcUserToDomainModel = (oidcUser: OidcUser): User => {
544
549
  let passport: PassportMetadata | undefined;
550
+ let username: string | undefined;
545
551
  if (oidcUser.id_token) {
546
- passport = jwt_decode<IdTokenPayload>(oidcUser.id_token)?.passport;
552
+ const idTokenPayload = decodeJwtPayload<IdTokenPayload>(oidcUser.id_token);
553
+ passport = idTokenPayload?.passport;
554
+ if (idTokenPayload?.username) {
555
+ username = idTokenPayload?.username;
556
+ }
547
557
  }
548
558
 
549
559
  const user: User = {
@@ -555,6 +565,7 @@ export class Auth {
555
565
  sub: oidcUser.profile.sub,
556
566
  email: oidcUser.profile.email,
557
567
  nickname: oidcUser.profile.nickname,
568
+ username,
558
569
  },
559
570
  };
560
571
  if (passport?.zkevm_eth_address && passport?.zkevm_user_admin_address) {
@@ -567,7 +578,7 @@ export class Auth {
567
578
  };
568
579
 
569
580
  private static mapDeviceTokenResponseToOidcUser = (tokenResponse: DeviceTokenResponse): OidcUser => {
570
- const idTokenPayload: IdTokenPayload = jwt_decode(tokenResponse.id_token);
581
+ const idTokenPayload: IdTokenPayload = decodeJwtPayload(tokenResponse.id_token);
571
582
  return new OidcUser({
572
583
  id_token: tokenResponse.id_token,
573
584
  access_token: tokenResponse.access_token,
@@ -582,29 +593,13 @@ export class Auth {
582
593
  email: idTokenPayload.email,
583
594
  nickname: idTokenPayload.nickname,
584
595
  passport: idTokenPayload.passport,
596
+ ...(idTokenPayload.username ? { username: idTokenPayload.username } : {}),
585
597
  },
586
598
  });
587
599
  };
588
600
 
589
- private static shouldUseSigninPopupCallback(): boolean {
590
- try {
591
- const urlParams = new URLSearchParams(window.location.search);
592
- const stateParam = urlParams.get('state');
593
- const localStorageKey = `oidc.${stateParam}`;
594
- const localStorageValue = localStorage.getItem(localStorageKey);
595
- const loginState = JSON.parse(localStorageValue || '{}');
596
- return loginState?.request_type === 'si:p';
597
- } catch (err) {
598
- return false;
599
- }
600
- }
601
-
602
601
  private async loginCallbackInternal(): Promise<User | undefined> {
603
602
  return withPassportError(async () => {
604
- if (Auth.shouldUseSigninPopupCallback()) {
605
- await this.userManager.signinPopupCallback(undefined, true);
606
- return undefined;
607
- }
608
603
  const oidcUser = await this.userManager.signinCallback();
609
604
  if (!oidcUser) {
610
605
  return undefined;
@@ -681,18 +676,39 @@ export class Auth {
681
676
  }
682
677
 
683
678
  private async getPKCEToken(authorizationCode: string, codeVerifier: string): Promise<DeviceTokenResponse> {
684
- const response = await axios.post<DeviceTokenResponse>(
679
+ const response = await fetch(
685
680
  `${this.config.authenticationDomain}/oauth/token`,
686
681
  {
687
- client_id: this.config.oidcConfiguration.clientId,
688
- grant_type: 'authorization_code',
689
- code_verifier: codeVerifier,
690
- code: authorizationCode,
691
- redirect_uri: this.config.oidcConfiguration.redirectUri,
682
+ method: 'POST',
683
+ headers: formUrlEncodedHeaders,
684
+ body: new URLSearchParams({
685
+ client_id: this.config.oidcConfiguration.clientId,
686
+ grant_type: 'authorization_code',
687
+ code_verifier: codeVerifier,
688
+ code: authorizationCode,
689
+ redirect_uri: this.config.oidcConfiguration.redirectUri,
690
+ }),
692
691
  },
693
- formUrlEncodedHeader,
694
692
  );
695
- return response.data;
693
+
694
+ const responseText = await response.text();
695
+ const parsedBody = parseJsonSafely(responseText);
696
+
697
+ if (!response.ok) {
698
+ throw new Error(
699
+ extractTokenErrorMessage(
700
+ parsedBody,
701
+ responseText,
702
+ response.status,
703
+ ),
704
+ );
705
+ }
706
+
707
+ if (!parsedBody || typeof parsedBody !== 'object') {
708
+ throw new Error('Token endpoint returned an invalid response');
709
+ }
710
+
711
+ return parsedBody as DeviceTokenResponse;
696
712
  }
697
713
 
698
714
  private async storeTokensInternal(tokenResponse: DeviceTokenResponse): Promise<User> {
@@ -0,0 +1,21 @@
1
+ import { isAPIError } from './errors';
2
+
3
+ describe('isAPIError', () => {
4
+ it('returns true when code and message fields exist', () => {
5
+ expect(isAPIError({ code: 'BAD_REQUEST', message: 'Invalid' })).toBe(true);
6
+ });
7
+
8
+ it.each([
9
+ 'Not found',
10
+ 404,
11
+ null,
12
+ undefined,
13
+ ])('returns false for non-object value: %p', (value) => {
14
+ expect(isAPIError(value)).toBe(false);
15
+ });
16
+
17
+ it('returns false when required fields are missing', () => {
18
+ expect(isAPIError({ code: 'BAD_REQUEST' })).toBe(false);
19
+ expect(isAPIError({ message: 'Invalid' })).toBe(false);
20
+ });
21
+ });
package/src/errors.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { isAxiosError } from 'axios';
2
1
  import { imx } from '@imtbl/generated-clients';
3
2
 
4
3
  export enum PassportErrorType {
@@ -23,12 +22,43 @@ export enum PassportErrorType {
23
22
  LINK_WALLET_DUPLICATE_NONCE_ERROR = 'LINK_WALLET_DUPLICATE_NONCE_ERROR',
24
23
  LINK_WALLET_GENERIC_ERROR = 'LINK_WALLET_GENERIC_ERROR',
25
24
  SERVICE_UNAVAILABLE_ERROR = 'SERVICE_UNAVAILABLE_ERROR',
25
+ TRANSACTION_REJECTED = 'TRANSACTION_REJECTED',
26
26
  }
27
27
 
28
28
  export function isAPIError(error: any): error is imx.APIError {
29
- return 'code' in error && 'message' in error;
29
+ return (
30
+ typeof error === 'object'
31
+ && error !== null
32
+ && 'code' in error
33
+ && 'message' in error
34
+ );
30
35
  }
31
36
 
37
+ type AxiosLikeError = {
38
+ response?: {
39
+ data?: unknown;
40
+ };
41
+ };
42
+
43
+ const extractApiError = (error: unknown): imx.APIError | undefined => {
44
+ if (isAPIError(error)) {
45
+ return error;
46
+ }
47
+
48
+ if (
49
+ typeof error === 'object'
50
+ && error !== null
51
+ && 'response' in error
52
+ ) {
53
+ const { response } = error as AxiosLikeError;
54
+ if (response?.data && isAPIError(response.data)) {
55
+ return response.data;
56
+ }
57
+ }
58
+
59
+ return undefined;
60
+ };
61
+
32
62
  export class PassportError extends Error {
33
63
  public type: PassportErrorType;
34
64
 
@@ -51,8 +81,9 @@ export const withPassportError = async <T>(
51
81
  throw new PassportError(error.message, error.type);
52
82
  }
53
83
 
54
- if (isAxiosError(error) && error.response?.data && isAPIError(error.response.data)) {
55
- errorMessage = error.response.data.message;
84
+ const apiError = extractApiError(error);
85
+ if (apiError) {
86
+ errorMessage = apiError.message;
56
87
  } else {
57
88
  errorMessage = (error as Error).message;
58
89
  }
package/src/index.ts CHANGED
@@ -29,4 +29,8 @@ export {
29
29
  export { default as TypedEventEmitter } from './utils/typedEventEmitter';
30
30
 
31
31
  // Export errors
32
- export { PassportError, PassportErrorType, withPassportError } from './errors';
32
+ export {
33
+ PassportError, PassportErrorType, withPassportError, isAPIError,
34
+ } from './errors';
35
+
36
+ export { decodeJwtPayload } from './utils/jwt';
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable class-methods-use-this */
2
- import jwt_decode from 'jwt-decode';
3
2
  import { TokenPayload, PKCEData } from '../types';
3
+ import { decodeJwtPayload } from '../utils/jwt';
4
4
 
5
5
  const KEY_PKCE_STATE = 'pkce_state';
6
6
  const KEY_PKCE_VERIFIER = 'pkce_verifier';
@@ -9,7 +9,7 @@ const validCredentialsMinTtlSec = 3600; // 1 hour
9
9
  export default class DeviceCredentialsManager {
10
10
  private isTokenValid(jwt: string): boolean {
11
11
  try {
12
- const tokenPayload: TokenPayload = jwt_decode(jwt);
12
+ const tokenPayload: TokenPayload = decodeJwtPayload(jwt);
13
13
  const expiresAt = tokenPayload.exp ?? 0;
14
14
  const now = (Date.now() / 1000) + validCredentialsMinTtlSec;
15
15
  return expiresAt > now;
package/src/types.ts CHANGED
@@ -9,6 +9,7 @@ export type UserProfile = {
9
9
  email?: string;
10
10
  nickname?: string;
11
11
  sub: string;
12
+ username?: string;
12
13
  };
13
14
 
14
15
  export enum RollupType {
@@ -91,6 +92,7 @@ export type TokenPayload = {
91
92
 
92
93
  export type IdTokenPayload = {
93
94
  passport?: PassportMetadata;
95
+ username?: string;
94
96
  email: string;
95
97
  nickname: string;
96
98
  aud: string;
@@ -0,0 +1,78 @@
1
+ /* eslint-disable no-restricted-globals */
2
+ const getGlobal = (): typeof globalThis => {
3
+ if (typeof globalThis !== 'undefined') {
4
+ return globalThis;
5
+ }
6
+ if (typeof self !== 'undefined') {
7
+ return self;
8
+ }
9
+ if (typeof window !== 'undefined') {
10
+ return window;
11
+ }
12
+ if (typeof global !== 'undefined') {
13
+ return global;
14
+ }
15
+ return {} as typeof globalThis;
16
+ };
17
+
18
+ const base64UrlToBase64 = (input: string): string => {
19
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
20
+ const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
21
+ return normalized + padding;
22
+ };
23
+
24
+ const decodeWithAtob = (value: string): string | null => {
25
+ const globalRef = getGlobal();
26
+ if (typeof globalRef.atob !== 'function') {
27
+ return null;
28
+ }
29
+
30
+ const binary = globalRef.atob(value);
31
+ const bytes = new Uint8Array(binary.length);
32
+ for (let i = 0; i < binary.length; i += 1) {
33
+ bytes[i] = binary.charCodeAt(i);
34
+ }
35
+
36
+ if (typeof globalRef.TextDecoder === 'function') {
37
+ return new globalRef.TextDecoder('utf-8').decode(bytes);
38
+ }
39
+
40
+ let result = '';
41
+ for (let i = 0; i < bytes.length; i += 1) {
42
+ result += String.fromCharCode(bytes[i]);
43
+ }
44
+ return result;
45
+ };
46
+
47
+ const base64Decode = (value: string): string => {
48
+ if (typeof Buffer !== 'undefined') {
49
+ return Buffer.from(value, 'base64').toString('utf-8');
50
+ }
51
+
52
+ const decoded = decodeWithAtob(value);
53
+ if (decoded === null) {
54
+ throw new Error('Base64 decoding is not supported in this environment');
55
+ }
56
+
57
+ return decoded;
58
+ };
59
+
60
+ export const decodeJwtPayload = <T>(token: string): T => {
61
+ if (typeof token !== 'string') {
62
+ throw new Error('JWT must be a string');
63
+ }
64
+
65
+ const segments = token.split('.');
66
+ if (segments.length < 2) {
67
+ throw new Error('Invalid JWT: payload segment is missing');
68
+ }
69
+
70
+ const payloadSegment = segments[1];
71
+ const json = base64Decode(base64UrlToBase64(payloadSegment));
72
+
73
+ try {
74
+ return JSON.parse(json) as T;
75
+ } catch {
76
+ throw new Error('Invalid JWT payload: unable to parse JSON');
77
+ }
78
+ };
@@ -1,13 +1,13 @@
1
- import jwt_decode from 'jwt-decode';
2
1
  import {
3
2
  User as OidcUser,
4
3
  } from 'oidc-client-ts';
5
4
  import { IdTokenPayload, TokenPayload } from '../types';
5
+ import { decodeJwtPayload } from './jwt';
6
6
 
7
7
  function isTokenExpiredOrExpiring(token: string): boolean {
8
8
  try {
9
9
  // try to decode the token as access token payload or id token payload
10
- const decodedToken = jwt_decode<TokenPayload | IdTokenPayload>(token);
10
+ const decodedToken = decodeJwtPayload<TokenPayload | IdTokenPayload>(token);
11
11
  const now = Math.floor(Date.now() / 1000);
12
12
 
13
13
  // Tokens without expiration claims are invalid (security vulnerability)
@@ -1,26 +1,51 @@
1
- import { EventEmitter } from 'events';
1
+ type StringEventKey<T> = Extract<keyof T, string>;
2
+
3
+ type AnyListener = (...args: any[]) => void;
4
+ type EventArgs<TEvents, TEventName extends keyof TEvents> =
5
+ TEvents[TEventName] extends readonly [...infer A]
6
+ ? [...A]
7
+ : TEvents[TEventName] extends readonly any[]
8
+ ? [...TEvents[TEventName]]
9
+ : [TEvents[TEventName]];
2
10
 
3
11
  export default class TypedEventEmitter<TEvents extends Record<string, any>> {
4
- private emitter = new EventEmitter();
12
+ private listeners = new Map<StringEventKey<TEvents>, Set<AnyListener>>();
5
13
 
6
- emit<TEventName extends keyof TEvents & string>(
14
+ emit<TEventName extends StringEventKey<TEvents>>(
7
15
  eventName: TEventName,
8
- ...eventArg: TEvents[TEventName]
16
+ ...eventArg: EventArgs<TEvents, TEventName>
9
17
  ) {
10
- this.emitter.emit(eventName, ...(eventArg as []));
18
+ const handlers = this.listeners.get(eventName);
19
+ if (!handlers || handlers.size === 0) {
20
+ return;
21
+ }
22
+
23
+ // Copy handlers to avoid issues if listeners mutate during emit
24
+ [...handlers].forEach((handler) => {
25
+ handler(...eventArg);
26
+ });
11
27
  }
12
28
 
13
- on<TEventName extends keyof TEvents & string>(
29
+ on<TEventName extends StringEventKey<TEvents>>(
14
30
  eventName: TEventName,
15
- handler: (...eventArg: TEvents[TEventName]) => void,
31
+ handler: (...eventArg: EventArgs<TEvents, TEventName>) => void,
16
32
  ) {
17
- this.emitter.on(eventName, handler as any);
33
+ const handlers = this.listeners.get(eventName) ?? new Set<AnyListener>();
34
+ handlers.add(handler as AnyListener);
35
+ this.listeners.set(eventName, handlers);
18
36
  }
19
37
 
20
- removeListener<TEventName extends keyof TEvents & string>(
38
+ removeListener<TEventName extends StringEventKey<TEvents>>(
21
39
  eventName: TEventName,
22
- handler: (...eventArg: TEvents[TEventName]) => void,
40
+ handler: (...eventArg: EventArgs<TEvents, TEventName>) => void,
23
41
  ) {
24
- this.emitter.removeListener(eventName, handler as any);
42
+ const handlers = this.listeners.get(eventName);
43
+ if (!handlers) {
44
+ return;
45
+ }
46
+ handlers.delete(handler as AnyListener);
47
+ if (handlers.size === 0) {
48
+ this.listeners.delete(eventName);
49
+ }
25
50
  }
26
51
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": [],
4
+ "include": ["src"]
5
+ }
6
+
package/tsconfig.json CHANGED
@@ -10,6 +10,10 @@
10
10
  "exclude": [
11
11
  "node_modules",
12
12
  "dist",
13
+ "src/**/*.test.ts",
14
+ "src/**/*.test.tsx",
15
+ "src/**/*.spec.ts",
16
+ "src/**/*.spec.tsx"
13
17
  ]
14
18
  }
15
19