diff --git a/.changeset/thin-students-lick.md b/.changeset/thin-students-lick.md new file mode 100644 index 0000000000..4b1c62bacd --- /dev/null +++ b/.changeset/thin-students-lick.md @@ -0,0 +1,5 @@ +--- +"@clerk/nuxt": patch +--- + +Allow custom middleware with options diff --git a/integration/presets/index.ts b/integration/presets/index.ts index 92d31062e5..ede126b607 100644 --- a/integration/presets/index.ts +++ b/integration/presets/index.ts @@ -5,6 +5,7 @@ import { expo } from './expo'; import { express } from './express'; import { createLongRunningApps } from './longRunningApps'; import { next } from './next'; +import { nuxt } from './nuxt'; import { react } from './react'; import { remix } from './remix'; import { tanstack } from './tanstack'; @@ -20,6 +21,7 @@ export const appConfigs = { expo, astro, tanstack, + nuxt, secrets: { instanceKeys, }, diff --git a/integration/tests/nuxt/middleware.test.ts b/integration/tests/nuxt/middleware.test.ts new file mode 100644 index 0000000000..edd6fed920 --- /dev/null +++ b/integration/tests/nuxt/middleware.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { createTestUtils } from '../../testUtils'; + +test.describe('custom middleware @nuxt', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + + test.beforeAll(async () => { + app = await appConfigs.nuxt.node + .clone() + .setName('nuxt-custom-middleware') + .addFile( + 'nuxt.config.js', + () => `export default defineNuxtConfig({ + modules: ['@clerk/nuxt'], + devtools: { enabled: false }, + clerk: { + skipServerMiddleware: true + } + });`, + ) + .addFile( + 'server/middleware/clerk.js', + () => `import { clerkMiddleware } from '@clerk/nuxt/server'; + + export default clerkMiddleware((event) => { + const { userId } = event.context.auth + if (!userId && event.path === '/api/me') { + throw createError({ + statusCode: 401, + statusMessage: 'You are not authorized to access this resource.' + }) + } + }); + `, + ) + .addFile( + 'pages/me.vue', + () => ` + + `, + ) + .commit(); + + await app.setup(); + await app.withEnv(appConfigs.envs.withCustomRoles); + await app.dev(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('guard API route with custom middleware', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + + // Verify unauthorized access is blocked + await u.page.goToAppHome(); + await u.po.expect.toBeSignedOut(); + await u.page.goToRelative('/me'); + await expect(u.page.getByText('401: You are not authorized to access this resource')).toBeVisible(); + + // Sign in flow + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + + // Verify authorized access works + await u.page.goToRelative('/me'); + await expect(u.page.getByText(`Hello, ${fakeUser.firstName}`)).toBeVisible(); + + await fakeUser.deleteIfExists(); + }); +}); diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index f199d27dc3..cab6860a0c 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -46,7 +46,8 @@ "lint": "eslint src/", "lint:attw": "attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm", "lint:publint": "publint", - "publish:local": "pnpm yalc push --replace --sig" + "publish:local": "pnpm yalc push --replace --sig", + "test": "vitest" }, "dependencies": { "@clerk/backend": "workspace:*", diff --git a/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts b/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts new file mode 100644 index 0000000000..89766f0b1e --- /dev/null +++ b/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts @@ -0,0 +1,106 @@ +import { createApp, eventHandler, setResponseHeader, toWebHandler } from 'h3'; +import { vi } from 'vitest'; + +import { clerkMiddleware } from '../clerkMiddleware'; + +const AUTH_RESPONSE = { + userId: 'user_2jZSstSbxtTndD9P7q4kDl0VVZa', + sessionId: 'sess_2jZSstSbxtTndD9P7q4kDl0VVZa', +}; + +const MOCK_OPTIONS = { + secretKey: 'sk_test_xxxxxxxxxxxxxxxxxx', + publishableKey: 'pk_test_xxxxxxxxxxxxx', + signInUrl: '/foo', + signUpUrl: '/bar', +}; + +vi.mock('#imports', () => { + return { + useRuntimeConfig: () => ({}), + }; +}); + +const authenticateRequestMock = vi.fn().mockResolvedValue({ + toAuth: () => AUTH_RESPONSE, + headers: new Headers(), +}); + +vi.mock('../clerkClient', () => { + return { + clerkClient: () => ({ + authenticateRequest: authenticateRequestMock, + telemetry: { record: vi.fn() }, + }), + }; +}); + +describe('clerkMiddleware(params)', () => { + test('renders route as normally when used without params', async () => { + const app = createApp(); + const handler = toWebHandler(app); + app.use(clerkMiddleware()); + app.use( + '/', + eventHandler(event => event.context.auth), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(AUTH_RESPONSE); + }); + + test('renders route as normally when used with options param', async () => { + const app = createApp(); + const handler = toWebHandler(app); + app.use(clerkMiddleware(MOCK_OPTIONS)); + app.use( + '/', + eventHandler(event => event.context.auth), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + expect(authenticateRequestMock).toHaveBeenCalledWith(expect.any(Request), expect.objectContaining(MOCK_OPTIONS)); + expect(await response.json()).toEqual(AUTH_RESPONSE); + }); + + test('executes handler and renders route when used with a custom handler', async () => { + const app = createApp(); + const handler = toWebHandler(app); + app.use( + clerkMiddleware(event => { + setResponseHeader(event, 'a-custom-header', '1'); + }), + ); + app.use( + '/', + eventHandler(event => event.context.auth), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + expect(response.headers.get('a-custom-header')).toBe('1'); + expect(await response.json()).toEqual(AUTH_RESPONSE); + }); + + test('executes handler and renders route when used with a custom handler and options', async () => { + const app = createApp(); + const handler = toWebHandler(app); + app.use( + clerkMiddleware(event => { + setResponseHeader(event, 'a-custom-header', '1'); + }, MOCK_OPTIONS), + ); + app.use( + '/', + eventHandler(event => event.context.auth), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + expect(response.headers.get('a-custom-header')).toBe('1'); + expect(authenticateRequestMock).toHaveBeenCalledWith(expect.any(Request), expect.objectContaining(MOCK_OPTIONS)); + expect(await response.json()).toEqual(AUTH_RESPONSE); + }); +}); diff --git a/packages/nuxt/src/runtime/server/clerkMiddleware.ts b/packages/nuxt/src/runtime/server/clerkMiddleware.ts index 620182a9ea..8d38aa53fc 100644 --- a/packages/nuxt/src/runtime/server/clerkMiddleware.ts +++ b/packages/nuxt/src/runtime/server/clerkMiddleware.ts @@ -1,31 +1,65 @@ import type { AuthObject } from '@clerk/backend'; +import type { AuthenticateRequestOptions } from '@clerk/backend/internal'; import { AuthStatus, constants } from '@clerk/backend/internal'; -import type { H3Event } from 'h3'; +import { eventMethodCalled } from '@clerk/shared/telemetry'; +import type { EventHandler } from 'h3'; import { createError, eventHandler, setResponseHeader } from 'h3'; import { clerkClient } from './clerkClient'; import { createInitialState, toWebRequest } from './utils'; -type Handler = (event: H3Event) => void; +function parseHandlerAndOptions(args: unknown[]) { + return [ + typeof args[0] === 'function' ? args[0] : undefined, + (args.length === 2 ? args[1] : typeof args[0] === 'function' ? {} : args[0]) || {}, + ] as [EventHandler | undefined, AuthenticateRequestOptions]; +} + +interface ClerkMiddleware { + /** + * @example + * export default clerkMiddleware((event) => { ... }, options); + */ + (handler: EventHandler, options?: AuthenticateRequestOptions): ReturnType; + + /** + * @example + * export default clerkMiddleware(options); + */ + (options?: AuthenticateRequestOptions): ReturnType; +} /** - * Integrates Clerk authentication into your Nuxt application through Middleware. - * - * @param handler Optional callback function to handle the authenticated request + * Middleware for Nuxt that handles authentication and authorization with Clerk. * * @example - * Basic usage: + * Basic usage with options: * ```ts - * import { clerkMiddleware } from '@clerk/nuxt/server' - * - * export default clerkMiddleware() + * export default clerkMiddleware({ + * authorizedParties: ['https://example.com'] + * }) * ``` * * @example * With custom handler: * ```ts - * import { clerkMiddleware } from '@clerk/nuxt/server' + * export default clerkMiddleware((event) => { + * // Access auth data from the event context + * const { auth } = event.context + * + * // Example: Require authentication for all API routes + * if (!auth.userId && event.path.startsWith('/api')) { + * throw createError({ + * statusCode: 401, + * message: 'Unauthorized' + * }) + * } + * }) + * ``` * + * @example + * With custom handler and options: + * ```ts * export default clerkMiddleware((event) => { * // Access auth data from the event context * const { auth } = event.context @@ -37,14 +71,25 @@ type Handler = (event: H3Event) => void; * message: 'Unauthorized' * }) * } + * }, { + * authorizedParties: ['https://example.com'] * }) * ``` */ -export function clerkMiddleware(handler?: Handler) { +export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { + const [handler, options] = parseHandlerAndOptions(args); return eventHandler(async event => { const clerkRequest = toWebRequest(event); - const requestState = await clerkClient(event).authenticateRequest(clerkRequest); + clerkClient(event).telemetry.record( + eventMethodCalled('clerkMiddleware', { + handler: Boolean(handler), + satellite: Boolean(options.isSatellite), + proxy: Boolean(options.proxyUrl), + }), + ); + + const requestState = await clerkClient(event).authenticateRequest(clerkRequest, options); const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { @@ -69,7 +114,7 @@ export function clerkMiddleware(handler?: Handler) { handler?.(event); }); -} +}; declare module 'h3' { interface H3EventContext { diff --git a/packages/nuxt/vitest.config.ts b/packages/nuxt/vitest.config.ts new file mode 100644 index 0000000000..7382f40e7d --- /dev/null +++ b/packages/nuxt/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/turbo.json b/turbo.json index ea27da6e51..ccaee59409 100644 --- a/turbo.json +++ b/turbo.json @@ -223,7 +223,7 @@ "outputLogs": "new-only" }, "//#test:integration:nuxt": { - "dependsOn": ["@clerk/clerk-js#build", "@clerk/vue#build", "@clerk/nuxt#build"], + "dependsOn": ["@clerk/clerk-js#build", "@clerk/vue#build", "@clerk/backend#build", "@clerk/nuxt#build"], "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"], "inputs": ["integration/**"], "outputLogs": "new-only"