Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
214 changes: 214 additions & 0 deletions integration/tests/machine-auth/api-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
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 validate API key', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);

// No API key provided
const noKeyRes = await request.get(url.toString());
expect(noKeyRes.status()).toBe(401);

// Invalid API key
const invalidKeyRes = await request.get(url.toString(), {
headers: {
Authorization: 'Bearer invalid_key',
},
});
expect(invalidKeyRes.status()).toBe(401);

// Valid API key
const validKeyRes = await request.get(url.toString(), {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await validKeyRes.json();
expect(validKeyRes.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 validate API key', async ({ request }) => {
const url = new URL('/api/me', app.serverUrl);

// No API key provided
const noKeyRes = await request.get(url.toString());
expect(noKeyRes.status()).toBe(401);

// Invalid API key
const invalidKeyRes = await request.get(url.toString(), {
headers: {
Authorization: 'Bearer invalid_key',
},
});
expect(invalidKeyRes.status()).toBe(401);

// Valid API key
const validKeyRes = await request.get(url.toString(), {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await validKeyRes.json();
expect(validKeyRes.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);
});
});
60 changes: 60 additions & 0 deletions packages/backend/src/api/endpoints/APIKeysApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,67 @@ import { AbstractAPI } from './AbstractApi';

const basePath = '/api_keys';

type CreateAPIKeyParams = {
type?: 'api_key';
/**
* API key name
*/
name: string;
/**
* user or organization ID the API key is associated with
*/
subject: string;
/**
* API key description
*/
description?: string | null;
claims?: Record<string, any> | null;
scopes?: string[];
createdBy?: string | null;
secondsUntilExpiration?: number | null;
};

type RevokeAPIKeyParams = {
/**
* API key ID
*/
apiKeyId: string;
/**
* Reason for revocation
*/
revocationReason?: string | null;
};

export class APIKeysAPI extends AbstractAPI {
async create(params: CreateAPIKeyParams) {
return this.request<APIKey>({
method: 'POST',
path: basePath,
bodyParams: params,
});
}

async revoke(params: RevokeAPIKeyParams) {
const { apiKeyId, ...bodyParams } = params;

this.requireId(apiKeyId);

return this.request<APIKey>({
method: 'POST',
path: joinPaths(basePath, apiKeyId, 'revoke'),
bodyParams,
});
}

async getSecret(apiKeyId: string) {
this.requireId(apiKeyId);

return this.request<{ secret: string }>({
method: 'GET',
path: joinPaths(basePath, apiKeyId, 'secret'),
});
}

async verifySecret(secret: string) {
return this.request<APIKey>({
method: 'POST',
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export type {
* Resources
*/
export type {
APIKey,
ActorToken,
AccountlessApplication,
AllowlistIdentifier,
Expand Down
Loading
Loading