Skip to content

Commit bcdb00b

Browse files
authored
refactor(passport): inline+remove deps (#2751)
1 parent 240cd2f commit bcdb00b

34 files changed

+818
-380
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
"eslint-config-airbnb": "^19.0.4",
2525
"eslint-config-airbnb-typescript": "^17.0.0",
2626
"eslint-plugin-react-refresh": "latest",
27-
"events": "^3.1.0",
2827
"http-server": "^14.1.1",
2928
"husky": "^8.0.3",
3029
"lint-staged": "^13.2.0",
@@ -34,6 +33,7 @@
3433
"string_decoder": "^1.3.0",
3534
"syncpack": "^13.0.0",
3635
"tsup": "8.3.0",
36+
"typescript": "^5.6.2",
3737
"typedoc": "^0.26.5",
3838
"wsrun": "^5.2.4"
3939
},

packages/auth/package.json

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,11 @@
3434
"test": "jest"
3535
},
3636
"dependencies": {
37-
"@imtbl/config": "workspace:*",
3837
"@imtbl/metrics": "workspace:*",
39-
"axios": "^1.6.5",
40-
"jwt-decode": "^3.1.2",
4138
"localforage": "^1.10.0",
42-
"oidc-client-ts": "3.4.1",
43-
"uuid": "^9.0.1"
39+
"oidc-client-ts": "3.4.1"
4440
},
4541
"devDependencies": {
46-
"@imtbl/toolkit": "workspace:*",
4742
"@swc/core": "^1.3.36",
4843
"@swc/jest": "^0.2.37",
4944
"@types/jest": "^29.5.12",

packages/auth/src/Auth.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Auth } from './Auth';
22
import { AuthEvents, User } from './types';
33
import { withMetricsAsync } from './utils/metrics';
4-
import jwt_decode from 'jwt-decode';
4+
import { decodeJwtPayload } from './utils/jwt';
55

66
const trackFlowMock = jest.fn();
77
const trackErrorMock = jest.fn();
@@ -18,15 +18,17 @@ jest.mock('@imtbl/metrics', () => ({
1818
getDetail: (...args: any[]) => getDetailMock(...args),
1919
}));
2020

21-
jest.mock('jwt-decode', () => jest.fn());
21+
jest.mock('./utils/jwt', () => ({
22+
decodeJwtPayload: jest.fn(),
23+
}));
2224

2325
beforeEach(() => {
2426
trackFlowMock.mockReset();
2527
trackErrorMock.mockReset();
2628
identifyMock.mockReset();
2729
trackMock.mockReset();
2830
getDetailMock.mockReset();
29-
(jwt_decode as jest.Mock).mockReset();
31+
(decodeJwtPayload as jest.Mock).mockReset();
3032
});
3133

3234
describe('withMetricsAsync', () => {
@@ -145,14 +147,14 @@ describe('Auth', () => {
145147
profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
146148
};
147149

148-
(jwt_decode as jest.Mock).mockReturnValue({
150+
(decodeJwtPayload as jest.Mock).mockReturnValue({
149151
username: 'username123',
150152
passport: undefined,
151153
});
152154

153155
const result = (Auth as any).mapOidcUserToDomainModel(mockOidcUser);
154156

155-
expect(jwt_decode).toHaveBeenCalledWith('token');
157+
expect(decodeJwtPayload).toHaveBeenCalledWith('token');
156158
expect(result.profile.username).toEqual('username123');
157159
});
158160

@@ -165,7 +167,7 @@ describe('Auth', () => {
165167
expires_in: 3600,
166168
};
167169

168-
(jwt_decode as jest.Mock).mockReturnValue({
170+
(decodeJwtPayload as jest.Mock).mockReturnValue({
169171
sub: 'user-123',
170172
iss: 'issuer',
171173
aud: 'audience',
@@ -179,7 +181,7 @@ describe('Auth', () => {
179181

180182
const oidcUser = (Auth as any).mapDeviceTokenResponseToOidcUser(tokenResponse);
181183

182-
expect(jwt_decode).toHaveBeenCalledWith('token');
184+
expect(decodeJwtPayload).toHaveBeenCalledWith('token');
183185
expect(oidcUser.profile.username).toEqual('username123');
184186
});
185187
});

packages/auth/src/Auth.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import {
77
UserManagerSettings,
88
WebStorageStateStore,
99
} from 'oidc-client-ts';
10-
import axios from 'axios';
11-
import jwt_decode from 'jwt-decode';
1210
import localForage from 'localforage';
1311
import {
1412
Detail,
@@ -35,17 +33,45 @@ import {
3533
import EmbeddedLoginPrompt from './login/embeddedLoginPrompt';
3634
import TypedEventEmitter from './utils/typedEventEmitter';
3735
import { withMetricsAsync } from './utils/metrics';
36+
import { decodeJwtPayload } from './utils/jwt';
3837
import DeviceCredentialsManager from './storage/device_credentials_manager';
3938
import { PassportError, PassportErrorType, withPassportError } from './errors';
4039
import logger from './utils/logger';
4140
import { isAccessTokenExpiredOrExpiring } from './utils/token';
4241
import LoginPopupOverlay from './overlay/loginPopupOverlay';
4342
import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage';
4443

45-
const formUrlEncodedHeader = {
46-
headers: {
47-
'Content-Type': 'application/x-www-form-urlencoded',
48-
},
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+
};
58+
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}`;
4975
};
5076

5177
const logoutEndpoint = '/v2/logout';
@@ -523,7 +549,7 @@ export class Auth {
523549
let passport: PassportMetadata | undefined;
524550
let username: string | undefined;
525551
if (oidcUser.id_token) {
526-
const idTokenPayload = jwt_decode<IdTokenPayload>(oidcUser.id_token);
552+
const idTokenPayload = decodeJwtPayload<IdTokenPayload>(oidcUser.id_token);
527553
passport = idTokenPayload?.passport;
528554
if (idTokenPayload?.username) {
529555
username = idTokenPayload?.username;
@@ -552,7 +578,7 @@ export class Auth {
552578
};
553579

554580
private static mapDeviceTokenResponseToOidcUser = (tokenResponse: DeviceTokenResponse): OidcUser => {
555-
const idTokenPayload: IdTokenPayload = jwt_decode(tokenResponse.id_token);
581+
const idTokenPayload: IdTokenPayload = decodeJwtPayload(tokenResponse.id_token);
556582
return new OidcUser({
557583
id_token: tokenResponse.id_token,
558584
access_token: tokenResponse.access_token,
@@ -650,18 +676,39 @@ export class Auth {
650676
}
651677

652678
private async getPKCEToken(authorizationCode: string, codeVerifier: string): Promise<DeviceTokenResponse> {
653-
const response = await axios.post<DeviceTokenResponse>(
679+
const response = await fetch(
654680
`${this.config.authenticationDomain}/oauth/token`,
655681
{
656-
client_id: this.config.oidcConfiguration.clientId,
657-
grant_type: 'authorization_code',
658-
code_verifier: codeVerifier,
659-
code: authorizationCode,
660-
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+
}),
661691
},
662-
formUrlEncodedHeader,
663692
);
664-
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;
665712
}
666713

667714
private async storeTokensInternal(tokenResponse: DeviceTokenResponse): Promise<User> {

packages/auth/src/errors.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { isAxiosError } from 'axios';
21
import { imx } from '@imtbl/generated-clients';
32

43
export enum PassportErrorType {
@@ -35,6 +34,31 @@ export function isAPIError(error: any): error is imx.APIError {
3534
);
3635
}
3736

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+
3862
export class PassportError extends Error {
3963
public type: PassportErrorType;
4064

@@ -57,8 +81,9 @@ export const withPassportError = async <T>(
5781
throw new PassportError(error.message, error.type);
5882
}
5983

60-
if (isAxiosError(error) && error.response?.data && isAPIError(error.response.data)) {
61-
errorMessage = error.response.data.message;
84+
const apiError = extractApiError(error);
85+
if (apiError) {
86+
errorMessage = apiError.message;
6287
} else {
6388
errorMessage = (error as Error).message;
6489
}

packages/auth/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ export { default as TypedEventEmitter } from './utils/typedEventEmitter';
3232
export {
3333
PassportError, PassportErrorType, withPassportError, isAPIError,
3434
} from './errors';
35+
36+
export { decodeJwtPayload } from './utils/jwt';

packages/auth/src/storage/device_credentials_manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable class-methods-use-this */
2-
import jwt_decode from 'jwt-decode';
32
import { TokenPayload, PKCEData } from '../types';
3+
import { decodeJwtPayload } from '../utils/jwt';
44

55
const KEY_PKCE_STATE = 'pkce_state';
66
const KEY_PKCE_VERIFIER = 'pkce_verifier';
@@ -9,7 +9,7 @@ const validCredentialsMinTtlSec = 3600; // 1 hour
99
export default class DeviceCredentialsManager {
1010
private isTokenValid(jwt: string): boolean {
1111
try {
12-
const tokenPayload: TokenPayload = jwt_decode(jwt);
12+
const tokenPayload: TokenPayload = decodeJwtPayload(jwt);
1313
const expiresAt = tokenPayload.exp ?? 0;
1414
const now = (Date.now() / 1000) + validCredentialsMinTtlSec;
1515
return expiresAt > now;

packages/auth/src/utils/jwt.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
};

packages/auth/src/utils/token.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import jwt_decode from 'jwt-decode';
21
import {
32
User as OidcUser,
43
} from 'oidc-client-ts';
54
import { IdTokenPayload, TokenPayload } from '../types';
5+
import { decodeJwtPayload } from './jwt';
66

77
function isTokenExpiredOrExpiring(token: string): boolean {
88
try {
99
// 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);
1111
const now = Math.floor(Date.now() / 1000);
1212

1313
// Tokens without expiration claims are invalid (security vulnerability)

0 commit comments

Comments
 (0)