Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-plugin-react-refresh": "latest",
"events": "^3.1.0",
"http-server": "^14.1.1",
"husky": "^8.0.3",
"lint-staged": "^13.2.0",
Expand All @@ -34,6 +33,7 @@
"string_decoder": "^1.3.0",
"syncpack": "^13.0.0",
"tsup": "8.3.0",
"typescript": "^5.6.2",
"typedoc": "^0.26.5",
"wsrun": "^5.2.4"
},
Expand Down
7 changes: 1 addition & 6 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,11 @@
"test": "jest"
},
"dependencies": {
"@imtbl/config": "workspace:*",
"@imtbl/metrics": "workspace:*",
"axios": "^1.6.5",
"jwt-decode": "^3.1.2",
"localforage": "^1.10.0",
"oidc-client-ts": "3.4.1",
"uuid": "^9.0.1"
"oidc-client-ts": "3.4.1"
},
"devDependencies": {
"@imtbl/toolkit": "workspace:*",
"@swc/core": "^1.3.36",
"@swc/jest": "^0.2.37",
"@types/jest": "^29.5.12",
Expand Down
16 changes: 9 additions & 7 deletions packages/auth/src/Auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Auth } from './Auth';
import { AuthEvents, User } from './types';
import { withMetricsAsync } from './utils/metrics';
import jwt_decode from 'jwt-decode';
import { decodeJwtPayload } from './utils/jwt';

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

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

beforeEach(() => {
trackFlowMock.mockReset();
trackErrorMock.mockReset();
identifyMock.mockReset();
trackMock.mockReset();
getDetailMock.mockReset();
(jwt_decode as jest.Mock).mockReset();
(decodeJwtPayload as jest.Mock).mockReset();
});

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

(jwt_decode as jest.Mock).mockReturnValue({
(decodeJwtPayload as jest.Mock).mockReturnValue({
username: 'username123',
passport: undefined,
});

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

expect(jwt_decode).toHaveBeenCalledWith('token');
expect(decodeJwtPayload).toHaveBeenCalledWith('token');
expect(result.profile.username).toEqual('username123');
});

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

(jwt_decode as jest.Mock).mockReturnValue({
(decodeJwtPayload as jest.Mock).mockReturnValue({
sub: 'user-123',
iss: 'issuer',
aud: 'audience',
Expand All @@ -179,7 +181,7 @@ describe('Auth', () => {

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

expect(jwt_decode).toHaveBeenCalledWith('token');
expect(decodeJwtPayload).toHaveBeenCalledWith('token');
expect(oidcUser.profile.username).toEqual('username123');
});
});
Expand Down
79 changes: 63 additions & 16 deletions packages/auth/src/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import {
UserManagerSettings,
WebStorageStateStore,
} from 'oidc-client-ts';
import axios from 'axios';
import jwt_decode from 'jwt-decode';
import localForage from 'localforage';
import {
Detail,
Expand All @@ -35,17 +33,45 @@ import {
import EmbeddedLoginPrompt from './login/embeddedLoginPrompt';
import TypedEventEmitter from './utils/typedEventEmitter';
import { withMetricsAsync } from './utils/metrics';
import { decodeJwtPayload } from './utils/jwt';
import DeviceCredentialsManager from './storage/device_credentials_manager';
import { PassportError, PassportErrorType, withPassportError } from './errors';
import logger from './utils/logger';
import { isAccessTokenExpiredOrExpiring } from './utils/token';
import LoginPopupOverlay from './overlay/loginPopupOverlay';
import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage';

const formUrlEncodedHeader = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
const formUrlEncodedHeaders = {
'Content-Type': 'application/x-www-form-urlencoded',
};

const parseJsonSafely = (text: string): unknown => {
if (!text) {
return undefined;
}
try {
return JSON.parse(text);
} catch {
return undefined;
}
};

const extractTokenErrorMessage = (
payload: unknown,
fallbackText: string,
status: number,
): string => {
if (payload && typeof payload === 'object') {
const data = payload as Record<string, unknown>;
const description = data.error_description ?? data.message ?? data.error;
if (typeof description === 'string' && description.trim().length > 0) {
return description;
}
}
if (fallbackText.trim().length > 0) {
return fallbackText;
}
return `Token request failed with status ${status}`;
};

const logoutEndpoint = '/v2/logout';
Expand Down Expand Up @@ -523,7 +549,7 @@ export class Auth {
let passport: PassportMetadata | undefined;
let username: string | undefined;
if (oidcUser.id_token) {
const idTokenPayload = jwt_decode<IdTokenPayload>(oidcUser.id_token);
const idTokenPayload = decodeJwtPayload<IdTokenPayload>(oidcUser.id_token);
passport = idTokenPayload?.passport;
if (idTokenPayload?.username) {
username = idTokenPayload?.username;
Expand Down Expand Up @@ -552,7 +578,7 @@ export class Auth {
};

private static mapDeviceTokenResponseToOidcUser = (tokenResponse: DeviceTokenResponse): OidcUser => {
const idTokenPayload: IdTokenPayload = jwt_decode(tokenResponse.id_token);
const idTokenPayload: IdTokenPayload = decodeJwtPayload(tokenResponse.id_token);
return new OidcUser({
id_token: tokenResponse.id_token,
access_token: tokenResponse.access_token,
Expand Down Expand Up @@ -650,18 +676,39 @@ export class Auth {
}

private async getPKCEToken(authorizationCode: string, codeVerifier: string): Promise<DeviceTokenResponse> {
const response = await axios.post<DeviceTokenResponse>(
const response = await fetch(
`${this.config.authenticationDomain}/oauth/token`,
{
client_id: this.config.oidcConfiguration.clientId,
grant_type: 'authorization_code',
code_verifier: codeVerifier,
code: authorizationCode,
redirect_uri: this.config.oidcConfiguration.redirectUri,
method: 'POST',
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: this.config.oidcConfiguration.clientId,
grant_type: 'authorization_code',
code_verifier: codeVerifier,
code: authorizationCode,
redirect_uri: this.config.oidcConfiguration.redirectUri,
}),
},
formUrlEncodedHeader,
);
return response.data;

const responseText = await response.text();
const parsedBody = parseJsonSafely(responseText);

if (!response.ok) {
throw new Error(
extractTokenErrorMessage(
parsedBody,
responseText,
response.status,
),
);
}

if (!parsedBody || typeof parsedBody !== 'object') {
throw new Error('Token endpoint returned an invalid response');
}

return parsedBody as DeviceTokenResponse;
}

private async storeTokensInternal(tokenResponse: DeviceTokenResponse): Promise<User> {
Expand Down
31 changes: 28 additions & 3 deletions packages/auth/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isAxiosError } from 'axios';
import { imx } from '@imtbl/generated-clients';

export enum PassportErrorType {
Expand Down Expand Up @@ -35,6 +34,31 @@ export function isAPIError(error: any): error is imx.APIError {
);
}

type AxiosLikeError = {
response?: {
data?: unknown;
};
};

const extractApiError = (error: unknown): imx.APIError | undefined => {
if (isAPIError(error)) {
return error;
}

if (
typeof error === 'object'
&& error !== null
&& 'response' in error
) {
const { response } = error as AxiosLikeError;
if (response?.data && isAPIError(response.data)) {
return response.data;
}
}

return undefined;
};

export class PassportError extends Error {
public type: PassportErrorType;

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

if (isAxiosError(error) && error.response?.data && isAPIError(error.response.data)) {
errorMessage = error.response.data.message;
const apiError = extractApiError(error);
if (apiError) {
errorMessage = apiError.message;
} else {
errorMessage = (error as Error).message;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ export { default as TypedEventEmitter } from './utils/typedEventEmitter';
export {
PassportError, PassportErrorType, withPassportError, isAPIError,
} from './errors';

export { decodeJwtPayload } from './utils/jwt';
4 changes: 2 additions & 2 deletions packages/auth/src/storage/device_credentials_manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable class-methods-use-this */
import jwt_decode from 'jwt-decode';
import { TokenPayload, PKCEData } from '../types';
import { decodeJwtPayload } from '../utils/jwt';

const KEY_PKCE_STATE = 'pkce_state';
const KEY_PKCE_VERIFIER = 'pkce_verifier';
Expand All @@ -9,7 +9,7 @@ const validCredentialsMinTtlSec = 3600; // 1 hour
export default class DeviceCredentialsManager {
private isTokenValid(jwt: string): boolean {
try {
const tokenPayload: TokenPayload = jwt_decode(jwt);
const tokenPayload: TokenPayload = decodeJwtPayload(jwt);
const expiresAt = tokenPayload.exp ?? 0;
const now = (Date.now() / 1000) + validCredentialsMinTtlSec;
return expiresAt > now;
Expand Down
78 changes: 78 additions & 0 deletions packages/auth/src/utils/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* eslint-disable no-restricted-globals */
const getGlobal = (): typeof globalThis => {
if (typeof globalThis !== 'undefined') {
return globalThis;
}
if (typeof self !== 'undefined') {
return self;
}
if (typeof window !== 'undefined') {
return window;
}
if (typeof global !== 'undefined') {
return global;
}
return {} as typeof globalThis;
};

const base64UrlToBase64 = (input: string): string => {
const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
return normalized + padding;
};

const decodeWithAtob = (value: string): string | null => {
const globalRef = getGlobal();
if (typeof globalRef.atob !== 'function') {
return null;
}

const binary = globalRef.atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}

if (typeof globalRef.TextDecoder === 'function') {
return new globalRef.TextDecoder('utf-8').decode(bytes);
}

let result = '';
for (let i = 0; i < bytes.length; i += 1) {
result += String.fromCharCode(bytes[i]);
}
return result;
};

const base64Decode = (value: string): string => {
if (typeof Buffer !== 'undefined') {
return Buffer.from(value, 'base64').toString('utf-8');
}

const decoded = decodeWithAtob(value);
if (decoded === null) {
throw new Error('Base64 decoding is not supported in this environment');
}

return decoded;
};

export const decodeJwtPayload = <T>(token: string): T => {
if (typeof token !== 'string') {
throw new Error('JWT must be a string');
}

const segments = token.split('.');
if (segments.length < 2) {
throw new Error('Invalid JWT: payload segment is missing');
}

const payloadSegment = segments[1];
const json = base64Decode(base64UrlToBase64(payloadSegment));

try {
return JSON.parse(json) as T;
} catch {
throw new Error('Invalid JWT payload: unable to parse JSON');
}
};
4 changes: 2 additions & 2 deletions packages/auth/src/utils/token.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import jwt_decode from 'jwt-decode';
import {
User as OidcUser,
} from 'oidc-client-ts';
import { IdTokenPayload, TokenPayload } from '../types';
import { decodeJwtPayload } from './jwt';

function isTokenExpiredOrExpiring(token: string): boolean {
try {
// try to decode the token as access token payload or id token payload
const decodedToken = jwt_decode<TokenPayload | IdTokenPayload>(token);
const decodedToken = decodeJwtPayload<TokenPayload | IdTokenPayload>(token);
const now = Math.floor(Date.now() / 1000);

// Tokens without expiration claims are invalid (security vulnerability)
Expand Down
Loading
Loading