Skip to content

Commit 240cd2f

Browse files
authored
feat(passport): v3 (#2744)
1 parent 8249f83 commit 240cd2f

File tree

171 files changed

+4995
-10661
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

171 files changed

+4995
-10661
lines changed

.husky/pre-commit

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env sh
22
. "$(dirname -- "$0")/_/husky.sh"
33

4-
# prevent heap limit allocation errors
5-
export NODE_OPTIONS="--max-old-space-size=4096"
4+
# prevent heap limit allocation errors - increased to 8GB
5+
export NODE_OPTIONS="--max-old-space-size=8192"
66

77
pnpm lint-staged

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@swc-node/register": "^1.10.9",
1414
"@swc/cli": "^0.4.0",
1515
"@swc/core": "^1.3.36",
16+
"@jest/test-sequencer": "^29.7.0",
1617
"@types/chai": "^4.3.16",
1718
"@typescript-eslint/eslint-plugin": "^5.57.1",
1819
"@typescript-eslint/parser": "^5.57.1",

packages/auth/.eslintrc.cjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module.exports = {
2+
extends: ['../../.eslintrc'],
3+
parserOptions: {
4+
project: './tsconfig.eslint.json',
5+
tsconfigRootDir: __dirname,
6+
},
7+
rules: {
8+
// Disable all import plugin rules due to stack overflow with auth package structure
9+
'import/order': 'off',
10+
'import/no-unresolved': 'off',
11+
'import/named': 'off',
12+
'import/default': 'off',
13+
'import/namespace': 'off',
14+
'import/no-cycle': 'off',
15+
'import/no-named-as-default': 'off',
16+
'import/no-named-as-default-member': 'off',
17+
},
18+
};

packages/auth/jest.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Config } from 'jest';
2+
import { execSync } from 'child_process';
3+
import { name } from './package.json';
4+
5+
const rootDirs = execSync(`pnpm --filter ${name}... exec pwd`)
6+
.toString()
7+
.split('\n')
8+
.filter(Boolean)
9+
.map((dir) => `${dir}/dist`);
10+
11+
const config: Config = {
12+
clearMocks: true,
13+
roots: ['<rootDir>/src', ...rootDirs],
14+
coverageProvider: 'v8',
15+
moduleDirectories: ['node_modules', 'src'],
16+
moduleNameMapper: { '^@imtbl/(.*)$': '<rootDir>/../../node_modules/@imtbl/$1/src' },
17+
testEnvironment: 'jsdom',
18+
transform: {
19+
'^.+\\.(t|j)sx?$': '@swc/jest',
20+
},
21+
transformIgnorePatterns: [],
22+
restoreMocks: true,
23+
setupFiles: ['<rootDir>/jest.setup.js'],
24+
};
25+
26+
export default config;
27+

packages/auth/jest.setup.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { TextEncoder } from 'util';
2+
3+
global.TextEncoder = TextEncoder;
4+

packages/auth/package.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"name": "@imtbl/auth",
3+
"version": "0.0.0",
4+
"description": "Authentication SDK for Immutable",
5+
"author": "Immutable",
6+
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
7+
"homepage": "https://github.com/immutable/ts-immutable-sdk#readme",
8+
"license": "Apache-2.0",
9+
"main": "dist/node/index.cjs",
10+
"module": "dist/node/index.js",
11+
"browser": "dist/browser/index.js",
12+
"types": "./dist/types/index.d.ts",
13+
"exports": {
14+
"development": {
15+
"types": "./src/index.ts",
16+
"browser": "./dist/browser/index.js",
17+
"require": "./dist/node/index.cjs",
18+
"default": "./dist/node/index.js"
19+
},
20+
"default": {
21+
"types": "./dist/types/index.d.ts",
22+
"browser": "./dist/browser/index.js",
23+
"require": "./dist/node/index.cjs",
24+
"default": "./dist/node/index.js"
25+
}
26+
},
27+
"scripts": {
28+
"build": "pnpm transpile && pnpm typegen",
29+
"transpile": "tsup src/index.ts --config ../../tsup.config.js",
30+
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
31+
"pack:root": "pnpm pack --pack-destination $(dirname $(pnpm root -w))",
32+
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
33+
"typecheck": "tsc --customConditions default --noEmit --jsx preserve",
34+
"test": "jest"
35+
},
36+
"dependencies": {
37+
"@imtbl/config": "workspace:*",
38+
"@imtbl/metrics": "workspace:*",
39+
"axios": "^1.6.5",
40+
"jwt-decode": "^3.1.2",
41+
"localforage": "^1.10.0",
42+
"oidc-client-ts": "3.4.1",
43+
"uuid": "^9.0.1"
44+
},
45+
"devDependencies": {
46+
"@imtbl/toolkit": "workspace:*",
47+
"@swc/core": "^1.3.36",
48+
"@swc/jest": "^0.2.37",
49+
"@types/jest": "^29.5.12",
50+
"@types/node": "^18.14.2",
51+
"@jest/test-sequencer": "^29.7.0",
52+
"jest": "^29.4.3",
53+
"jest-environment-jsdom": "^29.4.3",
54+
"ts-node": "^10.9.1",
55+
"tsup": "^8.3.0",
56+
"typescript": "^5.6.2"
57+
},
58+
"publishConfig": {
59+
"access": "public"
60+
},
61+
"repository": "immutable/ts-immutable-sdk.git",
62+
"type": "module"
63+
}
64+

packages/auth/src/Auth.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { Auth } from './Auth';
2+
import { AuthEvents, User } from './types';
3+
import { withMetricsAsync } from './utils/metrics';
4+
import jwt_decode from 'jwt-decode';
5+
6+
const trackFlowMock = jest.fn();
7+
const trackErrorMock = jest.fn();
8+
const identifyMock = jest.fn();
9+
const trackMock = jest.fn();
10+
const getDetailMock = jest.fn();
11+
12+
jest.mock('@imtbl/metrics', () => ({
13+
Detail: { RUNTIME_ID: 'runtime-id' },
14+
trackFlow: (...args: any[]) => trackFlowMock(...args),
15+
trackError: (...args: any[]) => trackErrorMock(...args),
16+
identify: (...args: any[]) => identifyMock(...args),
17+
track: (...args: any[]) => trackMock(...args),
18+
getDetail: (...args: any[]) => getDetailMock(...args),
19+
}));
20+
21+
jest.mock('jwt-decode', () => jest.fn());
22+
23+
beforeEach(() => {
24+
trackFlowMock.mockReset();
25+
trackErrorMock.mockReset();
26+
identifyMock.mockReset();
27+
trackMock.mockReset();
28+
getDetailMock.mockReset();
29+
(jwt_decode as jest.Mock).mockReset();
30+
});
31+
32+
describe('withMetricsAsync', () => {
33+
it('resolves with function result and tracks flow', async () => {
34+
const flow = {
35+
addEvent: jest.fn(),
36+
details: { flowId: 'flow-id' },
37+
};
38+
trackFlowMock.mockReturnValue(flow);
39+
40+
const result = await withMetricsAsync(async () => 'done', 'login');
41+
42+
expect(result).toEqual('done');
43+
expect(trackFlowMock).toHaveBeenCalledWith('passport', 'login', true);
44+
expect(flow.addEvent).toHaveBeenCalledWith('End');
45+
});
46+
47+
it('tracks error when function throws', async () => {
48+
const flow = {
49+
addEvent: jest.fn(),
50+
details: { flowId: 'flow-id' },
51+
};
52+
trackFlowMock.mockReturnValue(flow);
53+
const error = new Error('boom');
54+
55+
await expect(withMetricsAsync(async () => {
56+
throw error;
57+
}, 'login')).rejects.toThrow(error);
58+
59+
expect(trackErrorMock).toHaveBeenCalledWith('passport', 'login', error, { flowId: 'flow-id' });
60+
expect(flow.addEvent).toHaveBeenCalledWith('End');
61+
});
62+
63+
it('does not fail when non-error is thrown', async () => {
64+
const flow = {
65+
addEvent: jest.fn(),
66+
details: { flowId: 'flow-id' },
67+
};
68+
trackFlowMock.mockReturnValue(flow);
69+
70+
const nonError = { message: 'failure' };
71+
await expect(withMetricsAsync(async () => {
72+
throw nonError as unknown as Error;
73+
}, 'login')).rejects.toBe(nonError);
74+
75+
expect(flow.addEvent).toHaveBeenCalledWith('errored');
76+
});
77+
});
78+
79+
describe('Auth', () => {
80+
describe('getUserOrLogin', () => {
81+
const createMockUser = (): User => ({
82+
accessToken: 'access',
83+
idToken: 'id',
84+
refreshToken: 'refresh',
85+
expired: false,
86+
profile: {
87+
sub: 'user-123',
88+
email: 'test@example.com',
89+
nickname: 'tester',
90+
},
91+
});
92+
93+
it('emits LOGGED_IN event and identifies user when login is required', async () => {
94+
const auth = Object.create(Auth.prototype) as Auth;
95+
const loginWithPopup = jest.fn().mockResolvedValue(createMockUser());
96+
97+
(auth as any).eventEmitter = { emit: jest.fn() };
98+
(auth as any).getUserInternal = jest.fn().mockResolvedValue(null);
99+
(auth as any).loginWithPopup = loginWithPopup;
100+
101+
const user = await auth.getUserOrLogin();
102+
103+
expect(loginWithPopup).toHaveBeenCalledTimes(1);
104+
expect((auth as any).eventEmitter.emit).toHaveBeenCalledWith(AuthEvents.LOGGED_IN, user);
105+
expect(identifyMock).toHaveBeenCalledWith({ passportId: user.profile.sub });
106+
});
107+
108+
it('returns cached user without triggering login', async () => {
109+
const auth = Object.create(Auth.prototype) as Auth;
110+
const cachedUser = createMockUser();
111+
112+
(auth as any).eventEmitter = { emit: jest.fn() };
113+
(auth as any).getUserInternal = jest.fn().mockResolvedValue(cachedUser);
114+
(auth as any).loginWithPopup = jest.fn();
115+
116+
const user = await auth.getUserOrLogin();
117+
118+
expect(user).toBe(cachedUser);
119+
expect((auth as any).loginWithPopup).not.toHaveBeenCalled();
120+
expect((auth as any).eventEmitter.emit).not.toHaveBeenCalled();
121+
expect(identifyMock).not.toHaveBeenCalled();
122+
});
123+
});
124+
125+
describe('buildExtraQueryParams', () => {
126+
it('omits third_party_a_id when no anonymous id is provided', () => {
127+
const auth = Object.create(Auth.prototype) as Auth;
128+
(auth as any).userManager = { settings: { extraQueryParams: {} } };
129+
getDetailMock.mockReturnValue('runtime-id-value');
130+
131+
const params = (auth as any).buildExtraQueryParams();
132+
133+
expect(params.third_party_a_id).toBeUndefined();
134+
expect(params.rid).toEqual('runtime-id-value');
135+
});
136+
});
137+
138+
describe('username extraction', () => {
139+
it('extracts username from id token when present', () => {
140+
const mockOidcUser = {
141+
id_token: 'token',
142+
access_token: 'access',
143+
refresh_token: 'refresh',
144+
expired: false,
145+
profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' },
146+
};
147+
148+
(jwt_decode as jest.Mock).mockReturnValue({
149+
username: 'username123',
150+
passport: undefined,
151+
});
152+
153+
const result = (Auth as any).mapOidcUserToDomainModel(mockOidcUser);
154+
155+
expect(jwt_decode).toHaveBeenCalledWith('token');
156+
expect(result.profile.username).toEqual('username123');
157+
});
158+
159+
it('maps username when creating OIDC user from device tokens', () => {
160+
const tokenResponse = {
161+
id_token: 'token',
162+
access_token: 'access',
163+
refresh_token: 'refresh',
164+
token_type: 'Bearer',
165+
expires_in: 3600,
166+
};
167+
168+
(jwt_decode as jest.Mock).mockReturnValue({
169+
sub: 'user-123',
170+
iss: 'issuer',
171+
aud: 'audience',
172+
exp: 1,
173+
iat: 0,
174+
email: 'test@example.com',
175+
nickname: 'tester',
176+
username: 'username123',
177+
passport: undefined,
178+
});
179+
180+
const oidcUser = (Auth as any).mapDeviceTokenResponseToOidcUser(tokenResponse);
181+
182+
expect(jwt_decode).toHaveBeenCalledWith('token');
183+
expect(oidcUser.profile.username).toEqual('username123');
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)