Skip to content

Commit 59c4278

Browse files
authored
chore(repo): Add machine auth tests for TanStack Start and React Router (#8143)
1 parent f26d623 commit 59c4278

File tree

2 files changed

+745
-0
lines changed

2 files changed

+745
-0
lines changed
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
import type { User } from '@clerk/backend';
2+
import { createClerkClient } from '@clerk/backend';
3+
import { TokenType } from '@clerk/backend/internal';
4+
import { expect, test } from '@playwright/test';
5+
6+
import type { Application } from '../../models/application';
7+
import { appConfigs } from '../../presets';
8+
import { instanceKeys } from '../../presets/envs';
9+
import type { FakeAPIKey, FakeMachineNetwork, FakeOAuthApp, FakeUser } from '../../testUtils';
10+
import {
11+
createFakeMachineNetwork,
12+
createFakeOAuthApp,
13+
createJwtM2MToken,
14+
createTestUtils,
15+
obtainOAuthAccessToken,
16+
} from '../../testUtils';
17+
18+
test.describe('React Router machine authentication @machine', () => {
19+
test.describe('API key auth', () => {
20+
test.describe.configure({ mode: 'parallel' });
21+
let app: Application;
22+
let fakeUser: FakeUser;
23+
let fakeBapiUser: User;
24+
let fakeAPIKey: FakeAPIKey;
25+
26+
test.beforeAll(async () => {
27+
test.setTimeout(120_000);
28+
29+
app = await appConfigs.reactRouter.reactRouterNode
30+
.clone()
31+
.addFile(
32+
'app/routes/api/me.ts',
33+
() => `
34+
import { getAuth } from '@clerk/react-router/server';
35+
import type { Route } from './+types/me';
36+
37+
export async function loader(args: Route.LoaderArgs) {
38+
const { userId, tokenType } = await getAuth(args, { acceptsToken: 'api_key' });
39+
40+
if (!userId) {
41+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
42+
}
43+
44+
return Response.json({ userId, tokenType });
45+
}
46+
`,
47+
)
48+
.addFile(
49+
'app/routes.ts',
50+
() => `
51+
import { type RouteConfig, index, route } from '@react-router/dev/routes';
52+
53+
export default [
54+
index('routes/home.tsx'),
55+
route('sign-in/*', 'routes/sign-in.tsx'),
56+
route('sign-up/*', 'routes/sign-up.tsx'),
57+
route('protected', 'routes/protected.tsx'),
58+
route('api/me', 'routes/api/me.ts'),
59+
] satisfies RouteConfig;
60+
`,
61+
)
62+
.commit();
63+
64+
await app.setup();
65+
await app.withEnv(appConfigs.envs.withAPIKeys);
66+
await app.dev();
67+
68+
const u = createTestUtils({ app });
69+
fakeUser = u.services.users.createFakeUser();
70+
fakeBapiUser = await u.services.users.createBapiUser(fakeUser);
71+
fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
72+
});
73+
74+
test.afterAll(async () => {
75+
await fakeAPIKey.revoke();
76+
await fakeUser.deleteIfExists();
77+
await app.teardown();
78+
});
79+
80+
test('should return 401 if no API key is provided', async ({ request }) => {
81+
const url = new URL('/api/me', app.serverUrl);
82+
const res = await request.get(url.toString());
83+
expect(res.status()).toBe(401);
84+
});
85+
86+
test('should return 401 if API key is invalid', async ({ request }) => {
87+
const url = new URL('/api/me', app.serverUrl);
88+
const res = await request.get(url.toString(), {
89+
headers: { Authorization: 'Bearer invalid_key' },
90+
});
91+
expect(res.status()).toBe(401);
92+
});
93+
94+
test('should return 200 with auth object if API key is valid', async ({ request }) => {
95+
const url = new URL('/api/me', app.serverUrl);
96+
const res = await request.get(url.toString(), {
97+
headers: {
98+
Authorization: `Bearer ${fakeAPIKey.secret}`,
99+
},
100+
});
101+
const apiKeyData = await res.json();
102+
expect(res.status()).toBe(200);
103+
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
104+
expect(apiKeyData.tokenType).toBe(TokenType.ApiKey);
105+
});
106+
107+
for (const [tokenType, token] of [
108+
['M2M', 'mt_test_mismatch'],
109+
['OAuth', 'oat_test_mismatch'],
110+
] as const) {
111+
test(`rejects ${tokenType} token on API key route (token type mismatch)`, async ({ request }) => {
112+
const url = new URL('/api/me', app.serverUrl);
113+
const res = await request.get(url.toString(), {
114+
headers: { Authorization: `Bearer ${token}` },
115+
});
116+
expect(res.status()).toBe(401);
117+
});
118+
}
119+
});
120+
121+
test.describe('M2M auth', () => {
122+
test.describe.configure({ mode: 'parallel' });
123+
let app: Application;
124+
let network: FakeMachineNetwork;
125+
126+
test.beforeAll(async () => {
127+
test.setTimeout(120_000);
128+
129+
const client = createClerkClient({
130+
secretKey: instanceKeys.get('with-api-keys').sk,
131+
});
132+
network = await createFakeMachineNetwork(client);
133+
134+
app = await appConfigs.reactRouter.reactRouterNode
135+
.clone()
136+
.addFile(
137+
'app/routes/api/m2m.ts',
138+
() => `
139+
import { getAuth } from '@clerk/react-router/server';
140+
import type { Route } from './+types/m2m';
141+
142+
export async function loader(args: Route.LoaderArgs) {
143+
const { subject, tokenType, isAuthenticated } = await getAuth(args, { acceptsToken: 'm2m_token' });
144+
145+
if (!isAuthenticated) {
146+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
147+
}
148+
149+
return Response.json({ subject, tokenType });
150+
}
151+
`,
152+
)
153+
.addFile(
154+
'app/routes.ts',
155+
() => `
156+
import { type RouteConfig, index, route } from '@react-router/dev/routes';
157+
158+
export default [
159+
index('routes/home.tsx'),
160+
route('sign-in/*', 'routes/sign-in.tsx'),
161+
route('sign-up/*', 'routes/sign-up.tsx'),
162+
route('protected', 'routes/protected.tsx'),
163+
route('api/m2m', 'routes/api/m2m.ts'),
164+
] satisfies RouteConfig;
165+
`,
166+
)
167+
.commit();
168+
169+
await app.setup();
170+
171+
const env = appConfigs.envs.withAPIKeys
172+
.clone()
173+
.setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', network.primaryServer.secretKey);
174+
await app.withEnv(env);
175+
await app.dev();
176+
});
177+
178+
test.afterAll(async () => {
179+
await network.cleanup();
180+
await app.teardown();
181+
});
182+
183+
test('rejects requests with invalid M2M tokens', async ({ request }) => {
184+
const res = await request.get(app.serverUrl + '/api/m2m');
185+
expect(res.status()).toBe(401);
186+
187+
const res2 = await request.get(app.serverUrl + '/api/m2m', {
188+
headers: { Authorization: 'Bearer mt_xxx' },
189+
});
190+
expect(res2.status()).toBe(401);
191+
});
192+
193+
test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ request }) => {
194+
const res = await request.get(app.serverUrl + '/api/m2m', {
195+
headers: { Authorization: `Bearer ${network.unscopedSenderToken.token}` },
196+
});
197+
expect(res.status()).toBe(401);
198+
});
199+
200+
test('authorizes M2M requests when sender machine has proper access', async ({ page, context }) => {
201+
const u = createTestUtils({ app, page, context });
202+
203+
const res = await u.page.request.get(app.serverUrl + '/api/m2m', {
204+
headers: { Authorization: `Bearer ${network.scopedSenderToken.token}` },
205+
});
206+
expect(res.status()).toBe(200);
207+
const body = await res.json();
208+
expect(body.subject).toBe(network.scopedSender.id);
209+
expect(body.tokenType).toBe(TokenType.M2MToken);
210+
});
211+
212+
test('authorizes after dynamically granting scope', async ({ page, context }) => {
213+
const u = createTestUtils({ app, page, context });
214+
215+
await u.services.clerk.machines.createScope(network.unscopedSender.id, network.primaryServer.id);
216+
const m2mToken = await u.services.clerk.m2m.createToken({
217+
machineSecretKey: network.unscopedSender.secretKey,
218+
secondsUntilExpiration: 60 * 30,
219+
});
220+
221+
const res = await u.page.request.get(app.serverUrl + '/api/m2m', {
222+
headers: { Authorization: `Bearer ${m2mToken.token}` },
223+
});
224+
expect(res.status()).toBe(200);
225+
const body = await res.json();
226+
expect(body.subject).toBe(network.unscopedSender.id);
227+
expect(body.tokenType).toBe(TokenType.M2MToken);
228+
await u.services.clerk.m2m.revokeToken({ m2mTokenId: m2mToken.id });
229+
});
230+
231+
test('verifies JWT format M2M token via local verification', async ({ request }) => {
232+
const client = createClerkClient({
233+
secretKey: instanceKeys.get('with-api-keys').sk,
234+
});
235+
const jwtToken = await createJwtM2MToken(client, network.scopedSender.secretKey);
236+
237+
const res = await request.get(app.serverUrl + '/api/m2m', {
238+
headers: { Authorization: `Bearer ${jwtToken.token}` },
239+
});
240+
expect(res.status()).toBe(200);
241+
const body = await res.json();
242+
expect(body.subject).toBe(network.scopedSender.id);
243+
expect(body.tokenType).toBe(TokenType.M2MToken);
244+
});
245+
246+
for (const [tokenType, token] of [
247+
['API key', 'ak_test_mismatch'],
248+
['OAuth', 'oat_test_mismatch'],
249+
] as const) {
250+
test(`rejects ${tokenType} token on M2M route (token type mismatch)`, async ({ request }) => {
251+
const res = await request.get(app.serverUrl + '/api/m2m', {
252+
headers: { Authorization: `Bearer ${token}` },
253+
});
254+
expect(res.status()).toBe(401);
255+
});
256+
}
257+
});
258+
259+
test.describe('OAuth auth', () => {
260+
test.describe.configure({ mode: 'parallel' });
261+
let app: Application;
262+
let fakeUser: FakeUser;
263+
let fakeOAuth: FakeOAuthApp;
264+
265+
test.beforeAll(async () => {
266+
test.setTimeout(120_000);
267+
268+
app = await appConfigs.reactRouter.reactRouterNode
269+
.clone()
270+
.addFile(
271+
'app/routes/api/oauth-verify.ts',
272+
() => `
273+
import { getAuth } from '@clerk/react-router/server';
274+
import type { Route } from './+types/oauth-verify';
275+
276+
export async function loader(args: Route.LoaderArgs) {
277+
const { userId, tokenType } = await getAuth(args, { acceptsToken: 'oauth_token' });
278+
279+
if (!userId) {
280+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
281+
}
282+
283+
return Response.json({ userId, tokenType });
284+
}
285+
`,
286+
)
287+
.addFile(
288+
'app/routes/api/oauth-callback.ts',
289+
() => `
290+
export async function loader() {
291+
return Response.json({ message: 'OAuth callback received' });
292+
}
293+
`,
294+
)
295+
.addFile(
296+
'app/routes.ts',
297+
() => `
298+
import { type RouteConfig, index, route } from '@react-router/dev/routes';
299+
300+
export default [
301+
index('routes/home.tsx'),
302+
route('sign-in/*', 'routes/sign-in.tsx'),
303+
route('sign-up/*', 'routes/sign-up.tsx'),
304+
route('protected', 'routes/protected.tsx'),
305+
route('api/oauth-verify', 'routes/api/oauth-verify.ts'),
306+
route('api/oauth/callback', 'routes/api/oauth-callback.ts'),
307+
] satisfies RouteConfig;
308+
`,
309+
)
310+
.commit();
311+
312+
await app.setup();
313+
await app.withEnv(appConfigs.envs.withAPIKeys);
314+
await app.dev();
315+
316+
const u = createTestUtils({ app });
317+
fakeUser = u.services.users.createFakeUser();
318+
await u.services.users.createBapiUser(fakeUser);
319+
320+
const clerkClient = createClerkClient({
321+
secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'),
322+
publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'),
323+
});
324+
325+
fakeOAuth = await createFakeOAuthApp(clerkClient, `${app.serverUrl}/api/oauth/callback`);
326+
});
327+
328+
test.afterAll(async () => {
329+
await fakeOAuth.cleanup();
330+
await fakeUser.deleteIfExists();
331+
await app.teardown();
332+
});
333+
334+
test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => {
335+
const u = createTestUtils({ app, page, context });
336+
337+
const accessToken = await obtainOAuthAccessToken({
338+
page: u.page,
339+
oAuthApp: fakeOAuth.oAuthApp,
340+
redirectUri: `${app.serverUrl}/api/oauth/callback`,
341+
fakeUser,
342+
signIn: u.po.signIn,
343+
});
344+
345+
const res = await u.page.request.get(new URL('/api/oauth-verify', app.serverUrl).toString(), {
346+
headers: { Authorization: `Bearer ${accessToken}` },
347+
});
348+
expect(res.status()).toBe(200);
349+
const authData = await res.json();
350+
expect(authData.userId).toBeDefined();
351+
expect(authData.tokenType).toBe(TokenType.OAuthToken);
352+
});
353+
354+
test('rejects request without OAuth token', async ({ request }) => {
355+
const url = new URL('/api/oauth-verify', app.serverUrl);
356+
const res = await request.get(url.toString());
357+
expect(res.status()).toBe(401);
358+
});
359+
360+
test('rejects request with invalid OAuth token', async ({ request }) => {
361+
const url = new URL('/api/oauth-verify', app.serverUrl);
362+
const res = await request.get(url.toString(), {
363+
headers: { Authorization: 'Bearer invalid_oauth_token' },
364+
});
365+
expect(res.status()).toBe(401);
366+
});
367+
368+
for (const [tokenType, token] of [
369+
['API key', 'ak_test_mismatch'],
370+
['M2M', 'mt_test_mismatch'],
371+
] as const) {
372+
test(`rejects ${tokenType} token on OAuth route (token type mismatch)`, async ({ request }) => {
373+
const url = new URL('/api/oauth-verify', app.serverUrl);
374+
const res = await request.get(url.toString(), {
375+
headers: { Authorization: `Bearer ${token}` },
376+
});
377+
expect(res.status()).toBe(401);
378+
});
379+
}
380+
});
381+
});

0 commit comments

Comments
 (0)