Skip to content
Closed
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
69c4655
chore: add api key creation and revokation methods
wobsoriano Jun 19, 2025
792e7a0
chore: add initial api key e2e test with nextjs
wobsoriano Jun 19, 2025
d5afc7d
chore: add api keys service and fix misplaced token options
wobsoriano Jun 19, 2025
cbd280d
chore: simplify tests and handle revoked keys
wobsoriano Jun 19, 2025
47ce019
chore: remove revoke test forn ow
wobsoriano Jun 19, 2025
d082afe
chore: clean up
wobsoriano Jun 19, 2025
5fd1350
chore: remove test script
wobsoriano Jun 20, 2025
e327d62
Merge branch 'main' into rob/machine-auth-e2e
wobsoriano Jun 20, 2025
6b025d1
chore: test description updates
wobsoriano Jun 20, 2025
7cef4c4
chore: fix tests
wobsoriano Jun 20, 2025
959f624
chore: handle session token case
wobsoriano Jun 20, 2025
ec89710
test
wobsoriano Jun 20, 2025
73d92a1
chore: use api key testing with playwright
wobsoriano Jun 20, 2025
aea9fd8
chore: include api key listing
wobsoriano Jun 20, 2025
9693093
chore: log created key
wobsoriano Jun 20, 2025
480d1fb
chore: rerun
wobsoriano Jun 20, 2025
ca12300
chore: rerun
wobsoriano Jun 20, 2025
717d557
debug
wobsoriano Jun 20, 2025
b7250e5
debug
wobsoriano Jun 20, 2025
5ef2925
debug
wobsoriano Jun 20, 2025
bae7cd2
debug
wobsoriano Jun 20, 2025
2089e4e
debug
wobsoriano Jun 20, 2025
a0ed9c2
debug
wobsoriano Jun 20, 2025
15e83f4
debug
wobsoriano Jun 20, 2025
746d52e
debug
wobsoriano Jun 20, 2025
5a1b77a
debug
wobsoriano Jun 20, 2025
d9cd5a9
debug
wobsoriano Jun 20, 2025
310c4e2
debug
wobsoriano Jun 21, 2025
b06204b
clean up
wobsoriano Jun 21, 2025
760435e
debug
wobsoriano Jun 21, 2025
5dc91e5
test middleware
wobsoriano Jun 21, 2025
d3f6c05
revert
wobsoriano Jun 21, 2025
2fbbec4
chore: remove test prefixes
wobsoriano Jun 21, 2025
06f65fb
chore: rerun test
wobsoriano Jun 21, 2025
6ad36ee
chore: rerun test
wobsoriano Jun 21, 2025
2106816
chore: debug middleware
wobsoriano Jun 21, 2025
ed8b707
retry test
wobsoriano Jun 21, 2025
0957b5d
retry test
wobsoriano Jun 21, 2025
7087882
retry test
wobsoriano Jun 21, 2025
e8be4b3
retry test
wobsoriano Jun 21, 2025
fd3e62b
retry test
wobsoriano Jun 21, 2025
b4b53af
retry test
wobsoriano Jun 21, 2025
8daddc0
retry test
wobsoriano Jun 21, 2025
ac5022c
retry test
wobsoriano Jun 21, 2025
b257a0a
retry test
wobsoriano Jun 21, 2025
236fe8c
retry test
wobsoriano Jun 21, 2025
0451370
retry test
wobsoriano Jun 21, 2025
92a085e
retry test
wobsoriano Jun 21, 2025
f61cdc9
retry test
wobsoriano Jun 21, 2025
aeac516
retry test
wobsoriano Jun 21, 2025
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
22 changes: 22 additions & 0 deletions integration/templates/next-app-router/src/app/api/machine/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';

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

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

return NextResponse.json({ userId });
}

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

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

return NextResponse.json({ userId: authObject.userId });
}
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 THIRTY_MINUTES = 30 * 60;

const apiKey = await clerkClient.apiKeys.create({
subject: userId,
name: faker.company.buzzPhrase(),
secondsUntilExpiration: THIRTY_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
86 changes: 86 additions & 0 deletions integration/tests/api-keys/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { type User } from '@clerk/backend';
import { expect, test } from '@playwright/test';

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

testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('auth() with API keys @xnextjs', ({ app }) => {
test.describe.configure({ mode: 'parallel' });

let fakeUser: FakeUser;
let fakeBapiUser: User;
let fakeAPIKey: FakeAPIKey;
const u = createTestUtils({ app });

test.beforeAll(async () => {
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 () => {
const url = new URL('/api/machine', app.serverUrl);

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

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

// Valid API key
const validKeyRes = await fetch(url, {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await validKeyRes.json();
expect(validKeyRes.status).toBe(200);
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
});

test('should handle multiple token types', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const url = new URL('/api/machine', 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);

// Test with API key
const postWithApiKeyRes = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await postWithApiKeyRes.json();
expect(postWithApiKeyRes.status).toBe(200);
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
});
});
117 changes: 117 additions & 0 deletions integration/tests/api-keys/protect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { User } from '@clerk/backend';
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('auth.protect() with API keys @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/machine/route.ts',
() => `
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';

export async function GET() {
const { userId } = await auth.protect({ token: 'api_key' });
return NextResponse.json({ userId });
}

export async function POST() {
const { userId } = await auth.protect({ token: ['api_key', 'session_token'] });
return NextResponse.json({ userId });
}
`,
)
.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 () => {
const url = new URL('/api/machine', app.serverUrl);

// No API key provided
const noKeyRes = await fetch(url);
if (noKeyRes.status !== 401) {
console.log('Unexpected status for "noKeyRes". Status:', noKeyRes.status, noKeyRes.statusText);
const body = await noKeyRes.text();
console.log(`error body ${body} error body`);
}
expect(noKeyRes.status).toBe(401);

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

// Valid API key
const validKeyRes = await fetch(url, {
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await validKeyRes.json();
expect(validKeyRes.status).toBe(200);
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
});

test('should handle multiple token types', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const url = new URL('/api/machine', 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);

// Test with API key
const postWithApiKeyRes = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${fakeAPIKey.secret}`,
},
});
const apiKeyData = await postWithApiKeyRes.json();
expect(postWithApiKeyRes.status).toBe(200);
expect(apiKeyData.userId).toBe(fakeBapiUser.id);
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"test:integration:tanstack-react-router": "E2E_APP_ID=tanstack.react-router pnpm test:integration:base --grep @tanstack-react-router",
"test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start",
"test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue",
"test:integration:xnextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @xnextjs",
"test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run",
"turbo:clean": "turbo daemon clean",
"typedoc:generate": "pnpm build:declarations && typedoc --tsconfig tsconfig.typedoc.json",
Expand Down
Loading
Loading