Skip to content

Commit

Permalink
chore(nuxt): Allow custom middleware handler and options (#4655)
Browse files Browse the repository at this point in the history
  • Loading branch information
wobsoriano authored Nov 27, 2024
1 parent 23a9160 commit 82a5502
Show file tree
Hide file tree
Showing 8 changed files with 268 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/thin-students-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/nuxt": patch
---

Allow custom middleware with options
2 changes: 2 additions & 0 deletions integration/presets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,7 @@ export const appConfigs = {
expo,
astro,
tanstack,
nuxt,
secrets: {
instanceKeys,
},
Expand Down
87 changes: 87 additions & 0 deletions integration/tests/nuxt/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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',
() => `<script setup>
const { data, error } = await useFetch('/api/me');
</script>
<template>
<div v-if="data">Hello, {{ data.firstName }}</div>
<div v-else-if="error">{{ error.statusCode }}: {{ error.statusMessage }}</div>
<div v-else>Unknown status</div>
</template>`,
)
.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();
});
});
3 changes: 2 additions & 1 deletion packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
106 changes: 106 additions & 0 deletions packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
71 changes: 58 additions & 13 deletions packages/nuxt/src/runtime/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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<typeof eventHandler>;

/**
* @example
* export default clerkMiddleware(options);
*/
(options?: AuthenticateRequestOptions): ReturnType<typeof eventHandler>;
}

/**
* 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
Expand All @@ -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) {
Expand All @@ -69,7 +114,7 @@ export function clerkMiddleware(handler?: Handler) {

handler?.(event);
});
}
};

declare module 'h3' {
interface H3EventContext {
Expand Down
7 changes: 7 additions & 0 deletions packages/nuxt/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
globals: true,
},
});
2 changes: 1 addition & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 82a5502

Please sign in to comment.