Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
7 changes: 7 additions & 0 deletions .changeset/weak-adults-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@clerk/backend": minor
"@clerk/nextjs": patch
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
---

- Introduce API keys Backend SDK methods
- Fix `auth.protect()` unauthorized error bubbling within middleware
Comment thread
wobsoriano marked this conversation as resolved.
Outdated
4 changes: 4 additions & 0 deletions integration/.keys.json.sample
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,9 @@
"with-whatsapp-phone-code": {
"pk": "",
"sk": ""
},
"with-api-keys": {
"pk": "",
"sk": ""
}
}
7 changes: 7 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ const withWhatsappPhoneCode = base
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-whatsapp-phone-code').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-whatsapp-phone-code').pk);

const withAPIKeys = base
.clone()
.setId('withAPIKeys')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-api-keys').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-api-keys').pk);

export const envs = {
base,
withKeyless,
Expand All @@ -187,4 +193,5 @@ export const envs = {
withBillingStaging,
withBilling,
withWhatsappPhoneCode,
withAPIKeys,
} as const;
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const createLongRunningApps = () => {
config: next.appRouter,
env: envs.withSessionTasks,
},
{ id: 'next.appRouter.withAPIKeys', config: next.appRouter, env: envs.withAPIKeys },
{ id: 'withBillingStaging.next.appRouter', config: next.appRouter, env: envs.withBillingStaging },
{ id: 'withBilling.next.appRouter', config: next.appRouter, env: envs.withBilling },
{ id: 'withBillingStaging.vue.vite', config: vue.vite, env: envs.withBillingStaging },
Expand Down
4 changes: 2 additions & 2 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import type { Application } from '../models/application';
import { createEmailService } from './emailService';
import { createInvitationService } from './invitationsService';
import { createOrganizationsService } from './organizationsService';
import type { FakeOrganization, FakeUser } from './usersService';
import type { FakeAPIKey, FakeOrganization, FakeUser } from './usersService';
import { createUserService } from './usersService';

export type { FakeUser, FakeOrganization };
export type { FakeUser, FakeOrganization, FakeAPIKey };
const createClerkClient = (app: Application) => {
return backendCreateClerkClient({
apiUrl: app.env.privateVariables.get('CLERK_API_URL'),
Expand Down
26 changes: 25 additions & 1 deletion integration/testUtils/usersService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ClerkClient, Organization, User } from '@clerk/backend';
import type { APIKey, ClerkClient, Organization, User } from '@clerk/backend';
import { faker } from '@faker-js/faker';

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

export type FakeAPIKey = {
apiKey: APIKey;
secret: string;
revoke: () => Promise<APIKey>;
};

export type UserService = {
createFakeUser: (options?: FakeUserOptions) => FakeUser;
createBapiUser: (fakeUser: FakeUser) => Promise<User>;
Expand All @@ -67,6 +73,7 @@ export type UserService = {
deleteIfExists: (opts: { id?: string; email?: string; phoneNumber?: string }) => Promise<void>;
createFakeOrganization: (userId: string) => Promise<FakeOrganization>;
getUser: (opts: { id?: string; email?: string }) => Promise<User | undefined>;
createFakeAPIKey: (userId: string) => Promise<FakeAPIKey>;
};

/**
Expand Down Expand Up @@ -175,6 +182,23 @@ export const createUserService = (clerkClient: ClerkClient) => {
delete: () => clerkClient.organizations.deleteOrganization(organization.id),
} satisfies FakeOrganization;
},
createFakeAPIKey: async (userId: string) => {
const TEN_MINUTES = 10 * 60;

const apiKey = await clerkClient.apiKeys.create({
subject: userId,
name: faker.company.buzzPhrase(),
secondsUntilExpiration: TEN_MINUTES,
});

const { secret } = await clerkClient.apiKeys.getSecret(apiKey.id);

return {
apiKey,
secret,
revoke: () => clerkClient.apiKeys.revoke({ apiKeyId: apiKey.id, revocationReason: 'For testing purposes' }),
} satisfies FakeAPIKey;
},
};

return self;
Expand Down
238 changes: 238 additions & 0 deletions integration/tests/machine-auth/api-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import type { User } from '@clerk/backend';
import { TokenType } from '@clerk/backend/internal';
import { expect, test } from '@playwright/test';

import type { Application } from '../../models/application';
import { appConfigs } from '../../presets';
import type { FakeAPIKey, FakeUser } from '../../testUtils';
import { createTestUtils } from '../../testUtils';

test.describe('Next.js API key auth within clerkMiddleware() @nextjs', () => {
test.describe.configure({ mode: 'parallel' });
let app: Application;
let fakeUser: FakeUser;
let fakeBapiUser: User;
let fakeAPIKey: FakeAPIKey;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter
.clone()
.addFile(
`src/middleware.ts`,
() => `
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isProtectedRoute = createRouteMatcher(['/api(.*)']);

export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect({ token: 'api_key' });
}
});

export const config = {
matcher: [
'/((?!.*\\..*|_next).*)', // Don't run middleware on static files
'/', // Run middleware on index page
'/(api|trpc)(.*)',
], // Run middleware on API routes
};
`,
)
.addFile(
'src/app/api/me/route.ts',
() => `
import { auth } from '@clerk/nextjs/server';

export async function GET() {
const { userId, tokenType } = await auth({ acceptsToken: 'api_key' });

return Response.json({ userId, tokenType });
}
`,
)
.commit();

await app.setup();
await app.withEnv(appConfigs.envs.withAPIKeys);
await app.dev();

const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
fakeBapiUser = await u.services.users.createBapiUser(fakeUser);
fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
});

test.afterAll(async () => {
await fakeAPIKey.revoke();
await fakeUser.deleteIfExists();
await app.teardown();
});

test('should return 401 if no API key is provided', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const res = await request.get(url.toString());
expect(res.status()).toBe(401);
});

test('should return 401 if API key is invalid', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const res = await request.get(url.toString(), {
headers: { Authorization: 'Bearer invalid_key' },
});
expect(res.status()).toBe(401);
});

test('should return 401 if API key is revoked', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const u = createTestUtils({ app });
const tempApiKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
await tempApiKey.revoke();

const res = await request.get(url.toString(), {
headers: { Authorization: `Bearer ${tempApiKey.secret}` },
});
expect(res.status()).toBe(401);
});

test('should return 200 with auth object if API key is valid', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const res = await request.get(url.toString(), {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await res.json();
expect(res.status()).toBe(200);
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
expect(apiKeyData.tokenType).toBe(TokenType.ApiKey);
});
});

test.describe('Next.js API key auth within routes @nextjs', () => {
test.describe.configure({ mode: 'parallel' });
let app: Application;
let fakeUser: FakeUser;
let fakeBapiUser: User;
let fakeAPIKey: FakeAPIKey;

test.beforeAll(async () => {
app = await appConfigs.next.appRouter
.clone()
.addFile(
'src/app/api/me/route.ts',
() => `
import { auth } from '@clerk/nextjs/server';

export async function GET() {
const { userId, tokenType } = await auth({ acceptsToken: 'api_key' });

if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}

return Response.json({ userId, tokenType });
}

export async function POST() {
const authObject = await auth({ acceptsToken: ['api_key', 'session_token'] });

if (!authObject.isAuthenticated) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}

return Response.json({ userId: authObject.userId, tokenType: authObject.tokenType });
}
`,
)
.commit();

await app.setup();
await app.withEnv(appConfigs.envs.withAPIKeys);
await app.dev();

const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
fakeBapiUser = await u.services.users.createBapiUser(fakeUser);
fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
});

test.afterAll(async () => {
await fakeAPIKey.revoke();
await fakeUser.deleteIfExists();
await app.teardown();
});

test('should return 401 if no API key is provided', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const res = await request.get(url.toString());
expect(res.status()).toBe(401);
});

test('should return 401 if API key is invalid', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const res = await request.get(url.toString(), {
headers: { Authorization: 'Bearer invalid_key' },
});
expect(res.status()).toBe(401);
});

test('should return 401 if API key is revoked', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const u = createTestUtils({ app });
const tempApiKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id);
await tempApiKey.revoke();

const res = await request.get(url.toString(), {
headers: { Authorization: `Bearer ${tempApiKey.secret}` },
});
expect(res.status()).toBe(401);
});

test('should return 200 with auth object if API key is valid', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);
const res = await request.get(url.toString(), {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await res.json();
expect(res.status()).toBe(200);
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
expect(apiKeyData.tokenType).toBe(TokenType.ApiKey);
});

test('should handle multiple token types', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const url = new URL('/api/me', app.serverUrl);

// Sign in to get a session token
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

// GET endpoint (only accepts api_key)
const getRes = await u.page.request.get(url.toString());
expect(getRes.status()).toBe(401);

// POST endpoint (accepts both api_key and session_token)
// Test with session token
const postWithSessionRes = await u.page.request.post(url.toString());
const sessionData = await postWithSessionRes.json();
expect(postWithSessionRes.status()).toBe(200);
expect(sessionData.userId).toBe(fakeBapiUser.id);
expect(sessionData.tokenType).toBe(TokenType.SessionToken);

// Test with API key
const postWithApiKeyRes = await u.page.request.post(url.toString(), {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await postWithApiKeyRes.json();
expect(postWithApiKeyRes.status()).toBe(200);
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
expect(apiKeyData.tokenType).toBe(TokenType.ApiKey);
});
});
Loading
Loading