Skip to content

Commit 2692124

Browse files
authored
chore(backend,nextjs): Introduce API keys methods and integration tests (#6169)
1 parent 72629b0 commit 2692124

File tree

13 files changed

+381
-58
lines changed

13 files changed

+381
-58
lines changed

.changeset/eighty-frogs-return.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/nextjs": minor
3+
---
4+
5+
Fix `auth.protect()` unauthorized error propagation within middleware

.changeset/weak-adults-clean.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/backend": minor
3+
---
4+
5+
Introduce API keys Backend SDK methods

integration/.keys.json.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,9 @@
5454
"with-whatsapp-phone-code": {
5555
"pk": "",
5656
"sk": ""
57+
},
58+
"with-api-keys": {
59+
"pk": "",
60+
"sk": ""
5761
}
5862
}

integration/presets/envs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ const withWhatsappPhoneCode = base
163163
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-whatsapp-phone-code').sk)
164164
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-whatsapp-phone-code').pk);
165165

166+
const withAPIKeys = base
167+
.clone()
168+
.setId('withAPIKeys')
169+
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-api-keys').sk)
170+
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-api-keys').pk);
171+
166172
export const envs = {
167173
base,
168174
withKeyless,
@@ -187,4 +193,5 @@ export const envs = {
187193
withBillingStaging,
188194
withBilling,
189195
withWhatsappPhoneCode,
196+
withAPIKeys,
190197
} as const;

integration/presets/longRunningApps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const createLongRunningApps = () => {
4242
config: next.appRouter,
4343
env: envs.withSessionTasks,
4444
},
45+
{ id: 'next.appRouter.withAPIKeys', config: next.appRouter, env: envs.withAPIKeys },
4546
{ id: 'withBillingStaging.next.appRouter', config: next.appRouter, env: envs.withBillingStaging },
4647
{ id: 'withBilling.next.appRouter', config: next.appRouter, env: envs.withBilling },
4748
{ id: 'withBillingStaging.vue.vite', config: vue.vite, env: envs.withBillingStaging },

integration/testUtils/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import type { Application } from '../models/application';
66
import { createEmailService } from './emailService';
77
import { createInvitationService } from './invitationsService';
88
import { createOrganizationsService } from './organizationsService';
9-
import type { FakeOrganization, FakeUser } from './usersService';
9+
import type { FakeAPIKey, FakeOrganization, FakeUser } from './usersService';
1010
import { createUserService } from './usersService';
1111

12-
export type { FakeUser, FakeOrganization };
12+
export type { FakeUser, FakeOrganization, FakeAPIKey };
1313
const createClerkClient = (app: Application) => {
1414
return backendCreateClerkClient({
1515
apiUrl: app.env.privateVariables.get('CLERK_API_URL'),

integration/testUtils/usersService.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ClerkClient, Organization, User } from '@clerk/backend';
1+
import type { APIKey, ClerkClient, Organization, User } from '@clerk/backend';
22
import { faker } from '@faker-js/faker';
33

44
import { hash } from '../models/helpers';
@@ -57,6 +57,12 @@ export type FakeOrganization = {
5757
delete: () => Promise<Organization>;
5858
};
5959

60+
export type FakeAPIKey = {
61+
apiKey: APIKey;
62+
secret: string;
63+
revoke: () => Promise<APIKey>;
64+
};
65+
6066
export type UserService = {
6167
createFakeUser: (options?: FakeUserOptions) => FakeUser;
6268
createBapiUser: (fakeUser: FakeUser) => Promise<User>;
@@ -67,6 +73,7 @@ export type UserService = {
6773
deleteIfExists: (opts: { id?: string; email?: string; phoneNumber?: string }) => Promise<void>;
6874
createFakeOrganization: (userId: string) => Promise<FakeOrganization>;
6975
getUser: (opts: { id?: string; email?: string }) => Promise<User | undefined>;
76+
createFakeAPIKey: (userId: string) => Promise<FakeAPIKey>;
7077
};
7178

7279
/**
@@ -175,6 +182,23 @@ export const createUserService = (clerkClient: ClerkClient) => {
175182
delete: () => clerkClient.organizations.deleteOrganization(organization.id),
176183
} satisfies FakeOrganization;
177184
},
185+
createFakeAPIKey: async (userId: string) => {
186+
const TWENTY_MINUTES = 20 * 60;
187+
188+
const apiKey = await clerkClient.apiKeys.create({
189+
subject: userId,
190+
name: `Integration Test - ${userId}`,
191+
secondsUntilExpiration: TWENTY_MINUTES,
192+
});
193+
194+
const { secret } = await clerkClient.apiKeys.getSecret(apiKey.id);
195+
196+
return {
197+
apiKey,
198+
secret,
199+
revoke: () => clerkClient.apiKeys.revoke({ apiKeyId: apiKey.id, revocationReason: 'For testing purposes' }),
200+
} satisfies FakeAPIKey;
201+
},
178202
};
179203

180204
return self;
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import type { User } from '@clerk/backend';
2+
import { TokenType } from '@clerk/backend/internal';
3+
import { expect, test } from '@playwright/test';
4+
5+
import type { Application } from '../../models/application';
6+
import { appConfigs } from '../../presets';
7+
import type { FakeAPIKey, FakeUser } from '../../testUtils';
8+
import { createTestUtils } from '../../testUtils';
9+
10+
test.describe('Next.js API key auth within clerkMiddleware() @nextjs', () => {
11+
test.describe.configure({ mode: 'parallel' });
12+
let app: Application;
13+
let fakeUser: FakeUser;
14+
let fakeBapiUser: User;
15+
let fakeAPIKey: FakeAPIKey;
16+
17+
test.beforeAll(async () => {
18+
app = await appConfigs.next.appRouter
19+
.clone()
20+
.addFile(
21+
`src/middleware.ts`,
22+
() => `
23+
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
24+
25+
const isProtectedRoute = createRouteMatcher(['/api(.*)']);
26+
27+
export default clerkMiddleware(async (auth, req) => {
28+
if (isProtectedRoute(req)) {
29+
await auth.protect({ token: 'api_key' });
30+
}
31+
});
32+
33+
export const config = {
34+
matcher: [
35+
'/((?!.*\\..*|_next).*)', // Don't run middleware on static files
36+
'/', // Run middleware on index page
37+
'/(api|trpc)(.*)',
38+
], // Run middleware on API routes
39+
};
40+
`,
41+
)
42+
.addFile(
43+
'src/app/api/me/route.ts',
44+
() => `
45+
import { auth } from '@clerk/nextjs/server';
46+
47+
export async function GET() {
48+
const { userId, tokenType } = await auth({ acceptsToken: 'api_key' });
49+
50+
return Response.json({ userId, tokenType });
51+
}
52+
`,
53+
)
54+
.commit();
55+
56+
await app.setup();
57+
await app.withEnv(appConfigs.envs.withAPIKeys);
58+
await app.dev();
59+
60+
const u = createTestUtils({ app });
61+
fakeUser = u.services.users.createFakeUser();
62+
fakeBapiUser = await u.services.users.createBapiUser(fakeUser);
63+
fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
64+
});
65+
66+
test.afterAll(async () => {
67+
await fakeAPIKey.revoke();
68+
await fakeUser.deleteIfExists();
69+
await app.teardown();
70+
});
71+
72+
test('should return 401 if no API key is provided', async ({ request }) => {
73+
const url = new URL('/api/me', app.serverUrl);
74+
const res = await request.get(url.toString());
75+
expect(res.status()).toBe(401);
76+
});
77+
78+
test('should return 401 if API key is invalid', async ({ request }) => {
79+
const url = new URL('/api/me', app.serverUrl);
80+
const res = await request.get(url.toString(), {
81+
headers: { Authorization: 'Bearer invalid_key' },
82+
});
83+
expect(res.status()).toBe(401);
84+
});
85+
86+
test('should return 200 with auth object if API key is valid', async ({ request }) => {
87+
const url = new URL('/api/me', app.serverUrl);
88+
const res = await request.get(url.toString(), {
89+
headers: {
90+
Authorization: `Bearer ${fakeAPIKey.secret}`,
91+
},
92+
});
93+
const apiKeyData = await res.json();
94+
expect(res.status()).toBe(200);
95+
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
96+
expect(apiKeyData.tokenType).toBe(TokenType.ApiKey);
97+
});
98+
});
99+
100+
test.describe('Next.js API key auth within routes @nextjs', () => {
101+
test.describe.configure({ mode: 'parallel' });
102+
let app: Application;
103+
let fakeUser: FakeUser;
104+
let fakeBapiUser: User;
105+
let fakeAPIKey: FakeAPIKey;
106+
107+
test.beforeAll(async () => {
108+
app = await appConfigs.next.appRouter
109+
.clone()
110+
.addFile(
111+
'src/app/api/me/route.ts',
112+
() => `
113+
import { auth } from '@clerk/nextjs/server';
114+
115+
export async function GET() {
116+
const { userId, tokenType } = await auth({ acceptsToken: 'api_key' });
117+
118+
if (!userId) {
119+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
120+
}
121+
122+
return Response.json({ userId, tokenType });
123+
}
124+
125+
export async function POST() {
126+
const authObject = await auth({ acceptsToken: ['api_key', 'session_token'] });
127+
128+
if (!authObject.isAuthenticated) {
129+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
130+
}
131+
132+
return Response.json({ userId: authObject.userId, tokenType: authObject.tokenType });
133+
}
134+
`,
135+
)
136+
.commit();
137+
138+
await app.setup();
139+
await app.withEnv(appConfigs.envs.withAPIKeys);
140+
await app.dev();
141+
142+
const u = createTestUtils({ app });
143+
fakeUser = u.services.users.createFakeUser();
144+
fakeBapiUser = await u.services.users.createBapiUser(fakeUser);
145+
fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
146+
});
147+
148+
test.afterAll(async () => {
149+
await fakeAPIKey.revoke();
150+
await fakeUser.deleteIfExists();
151+
await app.teardown();
152+
});
153+
154+
test('should return 401 if no API key is provided', async ({ request }) => {
155+
const url = new URL('/api/me', app.serverUrl);
156+
const res = await request.get(url.toString());
157+
expect(res.status()).toBe(401);
158+
});
159+
160+
test('should return 401 if API key is invalid', async ({ request }) => {
161+
const url = new URL('/api/me', app.serverUrl);
162+
const res = await request.get(url.toString(), {
163+
headers: { Authorization: 'Bearer invalid_key' },
164+
});
165+
expect(res.status()).toBe(401);
166+
});
167+
168+
test('should return 200 with auth object if API key is valid', async ({ request }) => {
169+
const url = new URL('/api/me', app.serverUrl);
170+
const res = await request.get(url.toString(), {
171+
headers: {
172+
Authorization: `Bearer ${fakeAPIKey.secret}`,
173+
},
174+
});
175+
const apiKeyData = await res.json();
176+
expect(res.status()).toBe(200);
177+
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
178+
expect(apiKeyData.tokenType).toBe(TokenType.ApiKey);
179+
});
180+
181+
test('should handle multiple token types', async ({ page, context }) => {
182+
const u = createTestUtils({ app, page, context });
183+
const url = new URL('/api/me', app.serverUrl);
184+
185+
// Sign in to get a session token
186+
await u.po.signIn.goTo();
187+
await u.po.signIn.waitForMounted();
188+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
189+
await u.po.expect.toBeSignedIn();
190+
191+
// GET endpoint (only accepts api_key)
192+
const getRes = await u.page.request.get(url.toString());
193+
expect(getRes.status()).toBe(401);
194+
195+
// POST endpoint (accepts both api_key and session_token)
196+
// Test with session token
197+
const postWithSessionRes = await u.page.request.post(url.toString());
198+
const sessionData = await postWithSessionRes.json();
199+
expect(postWithSessionRes.status()).toBe(200);
200+
expect(sessionData.userId).toBe(fakeBapiUser.id);
201+
expect(sessionData.tokenType).toBe(TokenType.SessionToken);
202+
203+
// Test with API key
204+
const postWithApiKeyRes = await u.page.request.post(url.toString(), {
205+
headers: {
206+
Authorization: `Bearer ${fakeAPIKey.secret}`,
207+
},
208+
});
209+
const apiKeyData = await postWithApiKeyRes.json();
210+
expect(postWithApiKeyRes.status()).toBe(200);
211+
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
212+
expect(apiKeyData.tokenType).toBe(TokenType.ApiKey);
213+
});
214+
});

packages/backend/src/api/endpoints/APIKeysApi.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,67 @@ import { AbstractAPI } from './AbstractApi';
44

55
const basePath = '/api_keys';
66

7+
type CreateAPIKeyParams = {
8+
type?: 'api_key';
9+
/**
10+
* API key name
11+
*/
12+
name: string;
13+
/**
14+
* user or organization ID the API key is associated with
15+
*/
16+
subject: string;
17+
/**
18+
* API key description
19+
*/
20+
description?: string | null;
21+
claims?: Record<string, any> | null;
22+
scopes?: string[];
23+
createdBy?: string | null;
24+
secondsUntilExpiration?: number | null;
25+
};
26+
27+
type RevokeAPIKeyParams = {
28+
/**
29+
* API key ID
30+
*/
31+
apiKeyId: string;
32+
/**
33+
* Reason for revocation
34+
*/
35+
revocationReason?: string | null;
36+
};
37+
738
export class APIKeysAPI extends AbstractAPI {
39+
async create(params: CreateAPIKeyParams) {
40+
return this.request<APIKey>({
41+
method: 'POST',
42+
path: basePath,
43+
bodyParams: params,
44+
});
45+
}
46+
47+
async revoke(params: RevokeAPIKeyParams) {
48+
const { apiKeyId, ...bodyParams } = params;
49+
50+
this.requireId(apiKeyId);
51+
52+
return this.request<APIKey>({
53+
method: 'POST',
54+
path: joinPaths(basePath, apiKeyId, 'revoke'),
55+
bodyParams,
56+
});
57+
}
58+
59+
async getSecret(apiKeyId: string) {
60+
this.requireId(apiKeyId);
61+
62+
return this.request<{ secret: string }>({
63+
method: 'GET',
64+
path: joinPaths(basePath, apiKeyId, 'secret'),
65+
});
66+
}
67+
868
async verifySecret(secret: string) {
969
return this.request<APIKey>({
1070
method: 'POST',

0 commit comments

Comments
 (0)