Skip to content

Commit 7c1a4b4

Browse files
[Eng-962] Add apple login endpoint to comlink (backport #3214) (#3220)
Co-authored-by: Kefan Cao <[email protected]>
1 parent 3341e67 commit 7c1a4b4

File tree

8 files changed

+880
-2
lines changed

8 files changed

+880
-2
lines changed
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
import { AppleHelpers } from '../../src/lib/apple-helpers';
2+
import { TurnkeyError } from '../../src/lib/errors';
3+
import { AppleTokenResponse } from '../../src/types';
4+
import { SignJWT, importPKCS8 } from 'jose';
5+
import fetch from 'node-fetch';
6+
7+
// Mock dependencies
8+
jest.mock('jose');
9+
jest.mock('node-fetch');
10+
jest.mock('@dydxprotocol-indexer/base', () => ({
11+
logger: {
12+
error: jest.fn(),
13+
warning: jest.fn(),
14+
},
15+
}));
16+
17+
const mockSignJWT = SignJWT as jest.MockedClass<typeof SignJWT>;
18+
const mockImportPKCS8 = importPKCS8 as jest.MockedFunction<typeof importPKCS8>;
19+
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
20+
21+
describe('AppleHelpers', () => {
22+
const mockTeamId = 'TEAM123';
23+
const mockServiceId = 'com.example.app';
24+
const mockKeyId = 'KEY123';
25+
const mockPrivateKey = `-----BEGIN PRIVATE KEY-----
26+
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...
27+
-----END PRIVATE KEY-----`;
28+
const mockCode = 'auth_code_123';
29+
const mockKeyLike = { kty: 'EC' } as any;
30+
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
});
34+
35+
describe('generateClientSecret', () => {
36+
it('should generate a valid JWT client secret', async () => {
37+
const mockJwt = 'mock.jwt.token';
38+
const mockSignJWTInstance = {
39+
setProtectedHeader: jest.fn().mockReturnThis(),
40+
sign: jest.fn().mockResolvedValue(mockJwt),
41+
};
42+
43+
mockSignJWT.mockImplementation(() => mockSignJWTInstance as any);
44+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
45+
46+
const result = await AppleHelpers.generateClientSecret(
47+
mockTeamId,
48+
mockServiceId,
49+
mockKeyId,
50+
mockPrivateKey,
51+
);
52+
53+
expect(result).toBe(mockJwt);
54+
expect(mockImportPKCS8).toHaveBeenCalledWith(mockPrivateKey, 'ES256');
55+
expect(mockSignJWT).toHaveBeenCalledWith({
56+
iss: mockTeamId,
57+
iat: expect.any(Number),
58+
exp: expect.any(Number),
59+
aud: 'https://appleid.apple.com',
60+
sub: mockServiceId,
61+
});
62+
expect(mockSignJWTInstance.setProtectedHeader).toHaveBeenCalledWith({
63+
alg: 'ES256',
64+
kid: mockKeyId,
65+
});
66+
expect(mockSignJWTInstance.sign).toHaveBeenCalledWith(mockKeyLike);
67+
});
68+
69+
it('should set correct expiration time (6 months)', async () => {
70+
const mockJwt = 'mock.jwt.token';
71+
const mockSignJWTInstance = {
72+
setProtectedHeader: jest.fn().mockReturnThis(),
73+
sign: jest.fn().mockResolvedValue(mockJwt),
74+
};
75+
76+
mockSignJWT.mockImplementation(() => mockSignJWTInstance as any);
77+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
78+
79+
const now = Math.floor(Date.now() / 1000);
80+
81+
await AppleHelpers.generateClientSecret(
82+
mockTeamId,
83+
mockServiceId,
84+
mockKeyId,
85+
mockPrivateKey,
86+
);
87+
88+
const expectedExp = now + (60 * 60 * 24 * 180); // 6 months
89+
expect(mockSignJWT).toHaveBeenCalledWith({
90+
iss: mockTeamId,
91+
iat: now,
92+
exp: expectedExp,
93+
aud: 'https://appleid.apple.com',
94+
sub: mockServiceId,
95+
});
96+
});
97+
98+
it('should throw TurnkeyError when private key parsing fails', async () => {
99+
const errorMessage = 'Invalid private key format';
100+
mockImportPKCS8.mockRejectedValue(new Error(errorMessage));
101+
102+
await expect(
103+
AppleHelpers.generateClientSecret(
104+
mockTeamId,
105+
mockServiceId,
106+
mockKeyId,
107+
mockPrivateKey,
108+
),
109+
).rejects.toThrow(TurnkeyError);
110+
111+
await expect(
112+
AppleHelpers.generateClientSecret(
113+
mockTeamId,
114+
mockServiceId,
115+
mockKeyId,
116+
mockPrivateKey,
117+
),
118+
).rejects.toThrow(`Failed to generate Apple client secret: Failed to parse Apple private key: ${errorMessage}`);
119+
});
120+
121+
it('should throw TurnkeyError when JWT signing fails', async () => {
122+
const errorMessage = 'JWT signing failed';
123+
const mockSignJWTInstance = {
124+
setProtectedHeader: jest.fn().mockReturnThis(),
125+
sign: jest.fn().mockRejectedValue(new Error(errorMessage)),
126+
};
127+
128+
mockSignJWT.mockImplementation(() => mockSignJWTInstance as any);
129+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
130+
131+
await expect(
132+
AppleHelpers.generateClientSecret(
133+
mockTeamId,
134+
mockServiceId,
135+
mockKeyId,
136+
mockPrivateKey,
137+
),
138+
).rejects.toThrow(TurnkeyError);
139+
140+
await expect(
141+
AppleHelpers.generateClientSecret(
142+
mockTeamId,
143+
mockServiceId,
144+
mockKeyId,
145+
mockPrivateKey,
146+
),
147+
).rejects.toThrow(`Failed to generate Apple client secret: ${errorMessage}`);
148+
});
149+
});
150+
151+
describe('fetchTokenFromCode', () => {
152+
const mockTokenResponse: AppleTokenResponse = {
153+
access_token: 'access_token_123',
154+
token_type: 'Bearer',
155+
expires_in: 3600,
156+
refresh_token: 'refresh_token_123',
157+
id_token: 'id_token_123',
158+
};
159+
160+
it('should successfully exchange code for token', async () => {
161+
const mockJwt = 'mock.jwt.token';
162+
const mockSignJWTInstance = {
163+
setProtectedHeader: jest.fn().mockReturnThis(),
164+
sign: jest.fn().mockResolvedValue(mockJwt),
165+
};
166+
167+
mockSignJWT.mockImplementation(() => mockSignJWTInstance as any);
168+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
169+
170+
const mockResponse = {
171+
ok: true,
172+
json: jest.fn().mockResolvedValue(mockTokenResponse),
173+
};
174+
mockFetch.mockResolvedValue(mockResponse as any);
175+
176+
const result = await AppleHelpers.fetchTokenFromCode(
177+
mockCode,
178+
mockTeamId,
179+
mockServiceId,
180+
mockKeyId,
181+
mockPrivateKey,
182+
);
183+
184+
expect(result).toEqual(mockTokenResponse);
185+
expect(mockFetch).toHaveBeenCalledWith('https://appleid.apple.com/auth/token', {
186+
method: 'POST',
187+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
188+
body: expect.stringContaining('client_id=com.example.app'),
189+
});
190+
});
191+
192+
it('should include correct form parameters in request', async () => {
193+
const mockJwt = 'mock.jwt.token';
194+
const mockSignJWTInstance = {
195+
setProtectedHeader: jest.fn().mockReturnThis(),
196+
sign: jest.fn().mockResolvedValue(mockJwt),
197+
};
198+
199+
mockSignJWT.mockImplementation(() => mockSignJWTInstance as any);
200+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
201+
202+
const mockResponse = {
203+
ok: true,
204+
json: jest.fn().mockResolvedValue(mockTokenResponse),
205+
};
206+
mockFetch.mockResolvedValue(mockResponse as any);
207+
208+
await AppleHelpers.fetchTokenFromCode(
209+
mockCode,
210+
mockTeamId,
211+
mockServiceId,
212+
mockKeyId,
213+
mockPrivateKey,
214+
);
215+
216+
expect(mockFetch).toHaveBeenCalledWith(
217+
'https://appleid.apple.com/auth/token',
218+
expect.objectContaining({
219+
method: 'POST',
220+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
221+
body: expect.stringMatching(/client_id=com\.example\.app&client_secret=mock\.jwt\.token&code=auth_code_123&grant_type=authorization_code/),
222+
}),
223+
);
224+
});
225+
226+
it('should throw TurnkeyError when Apple API returns error', async () => {
227+
const mockJwt = 'mock.jwt.token';
228+
const mockSignJWTInstance = {
229+
setProtectedHeader: jest.fn().mockReturnThis(),
230+
sign: jest.fn().mockResolvedValue(mockJwt),
231+
};
232+
233+
mockSignJWT.mockImplementation(() => mockSignJWTInstance as any);
234+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
235+
236+
const errorText = 'invalid_grant';
237+
const mockResponse = {
238+
ok: false,
239+
status: 400,
240+
text: jest.fn().mockResolvedValue(errorText),
241+
};
242+
mockFetch.mockResolvedValue(mockResponse as any);
243+
244+
await expect(
245+
AppleHelpers.fetchTokenFromCode(
246+
mockCode,
247+
mockTeamId,
248+
mockServiceId,
249+
mockKeyId,
250+
mockPrivateKey,
251+
),
252+
).rejects.toThrow(TurnkeyError);
253+
254+
await expect(
255+
AppleHelpers.fetchTokenFromCode(
256+
mockCode,
257+
mockTeamId,
258+
mockServiceId,
259+
mockKeyId,
260+
mockPrivateKey,
261+
),
262+
).rejects.toThrow(`Apple token exchange failed: 400 ${errorText}`);
263+
});
264+
265+
it('should throw TurnkeyError when response has no id_token', async () => {
266+
const mockJwt = 'mock.jwt.token';
267+
const mockSignJWTInstance = {
268+
setProtectedHeader: jest.fn().mockReturnThis(),
269+
sign: jest.fn().mockResolvedValue(mockJwt),
270+
};
271+
272+
mockSignJWT.mockImplementation(() => mockSignJWTInstance as any);
273+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
274+
275+
const responseWithoutIdToken = {
276+
access_token: 'access_token_123',
277+
token_type: 'Bearer',
278+
expires_in: 3600,
279+
};
280+
281+
const mockResponse = {
282+
ok: true,
283+
json: jest.fn().mockResolvedValue(responseWithoutIdToken),
284+
};
285+
mockFetch.mockResolvedValue(mockResponse as any);
286+
287+
await expect(
288+
AppleHelpers.fetchTokenFromCode(
289+
mockCode,
290+
mockTeamId,
291+
mockServiceId,
292+
mockKeyId,
293+
mockPrivateKey,
294+
),
295+
).rejects.toThrow(TurnkeyError);
296+
297+
await expect(
298+
AppleHelpers.fetchTokenFromCode(
299+
mockCode,
300+
mockTeamId,
301+
mockServiceId,
302+
mockKeyId,
303+
mockPrivateKey,
304+
),
305+
).rejects.toThrow('No ID token received from Apple');
306+
});
307+
308+
it('should throw TurnkeyError when fetch fails', async () => {
309+
const errorMessage = 'Network error';
310+
mockImportPKCS8.mockRejectedValue(new Error(errorMessage));
311+
312+
await expect(
313+
AppleHelpers.fetchTokenFromCode(
314+
mockCode,
315+
mockTeamId,
316+
mockServiceId,
317+
mockKeyId,
318+
mockPrivateKey,
319+
),
320+
).rejects.toThrow(TurnkeyError);
321+
322+
await expect(
323+
AppleHelpers.fetchTokenFromCode(
324+
mockCode,
325+
mockTeamId,
326+
mockServiceId,
327+
mockKeyId,
328+
mockPrivateKey,
329+
),
330+
).rejects.toThrow(`Failed to fetch Apple token: Failed to generate Apple client secret: Failed to parse Apple private key: ${errorMessage}`);
331+
});
332+
});
333+
334+
describe('parsePrivateKey', () => {
335+
it('should successfully parse valid private key', async () => {
336+
mockImportPKCS8.mockResolvedValue(mockKeyLike);
337+
338+
const result = await AppleHelpers.parsePrivateKey(mockPrivateKey);
339+
340+
expect(result).toBe(mockKeyLike);
341+
expect(mockImportPKCS8).toHaveBeenCalledWith(mockPrivateKey, 'ES256');
342+
});
343+
344+
it('should throw TurnkeyError when private key is invalid', async () => {
345+
const errorMessage = 'Invalid private key format';
346+
mockImportPKCS8.mockRejectedValue(new Error(errorMessage));
347+
348+
await expect(
349+
AppleHelpers.parsePrivateKey('invalid_key'),
350+
).rejects.toThrow(TurnkeyError);
351+
352+
await expect(
353+
AppleHelpers.parsePrivateKey('invalid_key'),
354+
).rejects.toThrow(`Failed to parse Apple private key: ${errorMessage}`);
355+
});
356+
357+
it('should throw TurnkeyError when private key parsing throws non-Error', async () => {
358+
const errorMessage = 'Unknown error';
359+
mockImportPKCS8.mockRejectedValue(errorMessage);
360+
361+
await expect(
362+
AppleHelpers.parsePrivateKey('invalid_key'),
363+
).rejects.toThrow(TurnkeyError);
364+
365+
await expect(
366+
AppleHelpers.parsePrivateKey('invalid_key'),
367+
).rejects.toThrow(`Failed to parse Apple private key: ${errorMessage}`);
368+
});
369+
});
370+
371+
});

0 commit comments

Comments
 (0)