diff --git a/.changeset/cool-guests-trade.md b/.changeset/cool-guests-trade.md new file mode 100644 index 00000000000..6ff41bd66da --- /dev/null +++ b/.changeset/cool-guests-trade.md @@ -0,0 +1,24 @@ +--- +'@clerk/nuxt': minor +--- + +Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification. + +You can specify which token types are allowed by using the `acceptsToken` option in the `event.context.auth()` context. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens. + +Example usage: + +```ts +export default eventHandler((event) => { + const auth = event.locals.auth({ acceptsToken: 'any' }) + + if (authObject.tokenType === 'session_token') { + console.log('this is session token from a user') + } else { + console.log('this is some other type of machine token') + console.log('more specifically, a ' + authObject.tokenType) + } + + return {} +}) +``` \ No newline at end of file diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 8863eccd5f0..84fa45cb53f 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -110,13 +110,11 @@ export default defineNuxtModule({ { filename: 'types/clerk.d.ts', getContents: () => `import type { SessionAuthObject } from '@clerk/backend'; - declare module 'h3' { - type AuthObjectHandler = SessionAuthObject & { - (): SessionAuthObject; - } + import type { AuthFn } from '@clerk/nuxt/server'; + declare module 'h3' { interface H3EventContext { - auth: AuthObjectHandler; + auth: SessionAuthObject & AuthFn; } } `, diff --git a/packages/nuxt/src/runtime/server/__tests__/auth.test-d.ts b/packages/nuxt/src/runtime/server/__tests__/auth.test-d.ts new file mode 100644 index 00000000000..4f9e79c7f29 --- /dev/null +++ b/packages/nuxt/src/runtime/server/__tests__/auth.test-d.ts @@ -0,0 +1,56 @@ +import type { AuthObject, InvalidTokenAuthObject, MachineAuthObject, SessionAuthObject } from '@clerk/backend'; +import { expectTypeOf, test } from 'vitest'; + +import type { AuthFn } from '../types'; + +test('infers the correct AuthObject type for each accepted token type', () => { + // Mock event object + const event = { + locals: { + auth: (() => {}) as AuthFn, + }, + }; + + // Session token by default + expectTypeOf(event.locals.auth()).toMatchTypeOf(); + + // Individual token types + expectTypeOf(event.locals.auth({ acceptsToken: 'session_token' })).toMatchTypeOf(); + expectTypeOf(event.locals.auth({ acceptsToken: 'api_key' })).toMatchTypeOf>(); + expectTypeOf(event.locals.auth({ acceptsToken: 'machine_token' })).toMatchTypeOf< + MachineAuthObject<'machine_token'> + >(); + expectTypeOf(event.locals.auth({ acceptsToken: 'oauth_token' })).toMatchTypeOf>(); + + // Array of token types + expectTypeOf(event.locals.auth({ acceptsToken: ['session_token', 'machine_token'] })).toMatchTypeOf< + SessionAuthObject | MachineAuthObject<'machine_token'> | InvalidTokenAuthObject + >(); + expectTypeOf(event.locals.auth({ acceptsToken: ['machine_token', 'oauth_token'] })).toMatchTypeOf< + MachineAuthObject<'machine_token' | 'oauth_token'> | InvalidTokenAuthObject + >(); + + // Any token type + expectTypeOf(event.locals.auth({ acceptsToken: 'any' })).toMatchTypeOf(); +}); + +test('verifies discriminated union works correctly with acceptsToken: any', () => { + // Mock event object + const event = { + locals: { + auth: (() => {}) as AuthFn, + }, + }; + + const auth = event.locals.auth({ acceptsToken: 'any' }); + + if (auth.tokenType === 'session_token') { + expectTypeOf(auth).toMatchTypeOf(); + } else if (auth.tokenType === 'api_key') { + expectTypeOf(auth).toMatchTypeOf>(); + } else if (auth.tokenType === 'machine_token') { + expectTypeOf(auth).toMatchTypeOf>(); + } else if (auth.tokenType === 'oauth_token') { + expectTypeOf(auth).toMatchTypeOf>(); + } +}); diff --git a/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts b/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts index 03b8afdb2bf..9dd99f6d34f 100644 --- a/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nuxt/src/runtime/server/__tests__/clerkMiddleware.test.ts @@ -3,9 +3,31 @@ import { vi } from 'vitest'; import { clerkMiddleware } from '../clerkMiddleware'; -const AUTH_RESPONSE = { +const SESSION_AUTH_RESPONSE = { userId: 'user_2jZSstSbxtTndD9P7q4kDl0VVZa', sessionId: 'sess_2jZSstSbxtTndD9P7q4kDl0VVZa', + tokenType: 'session_token', + isAuthenticated: true, + sessionStatus: 'active', + sessionClaims: {}, + actor: null, + factorVerificationAge: null, + orgId: null, + orgRole: null, + orgSlug: null, + orgPermissions: null, +}; + +const MACHINE_AUTH_RESPONSE = { + id: 'ak_123456789', + subject: 'user_2jZSstSbxtTndD9P7q4kDl0VVZa', + scopes: ['read:users', 'write:users'], + tokenType: 'api_key', + isAuthenticated: true, + name: 'Test API Key', + claims: { custom: 'claim' }, + userId: 'user_2jZSstSbxtTndD9P7q4kDl0VVZa', + orgId: null, }; const MOCK_OPTIONS = { @@ -22,7 +44,7 @@ vi.mock('#imports', () => { }); const authenticateRequestMock = vi.fn().mockResolvedValue({ - toAuth: () => AUTH_RESPONSE, + toAuth: () => SESSION_AUTH_RESPONSE, headers: new Headers(), }); @@ -47,7 +69,7 @@ describe('clerkMiddleware(params)', () => { const response = await handler(new Request(new URL('/', 'http://localhost'))); expect(response.status).toBe(200); - expect(await response.json()).toEqual(AUTH_RESPONSE); + expect(await response.json()).toEqual(SESSION_AUTH_RESPONSE); }); test('renders route as normally when used with options param', async () => { @@ -62,7 +84,7 @@ describe('clerkMiddleware(params)', () => { expect(response.status).toBe(200); expect(authenticateRequestMock).toHaveBeenCalledWith(expect.any(Request), expect.objectContaining(MOCK_OPTIONS)); - expect(await response.json()).toEqual(AUTH_RESPONSE); + expect(await response.json()).toEqual(SESSION_AUTH_RESPONSE); }); test('executes handler and renders route when used with a custom handler', async () => { @@ -81,7 +103,7 @@ describe('clerkMiddleware(params)', () => { expect(response.status).toBe(200); expect(response.headers.get('a-custom-header')).toBe('1'); - expect(await response.json()).toEqual(AUTH_RESPONSE); + expect(await response.json()).toEqual(SESSION_AUTH_RESPONSE); }); test('executes handler and renders route when used with a custom handler and options', async () => { @@ -101,6 +123,108 @@ describe('clerkMiddleware(params)', () => { 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); + expect(await response.json()).toEqual(SESSION_AUTH_RESPONSE); + }); + + describe('machine authentication', () => { + test('returns machine auth object when acceptsToken is machine token type', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + toAuth: () => MACHINE_AUTH_RESPONSE, + headers: new Headers(), + }); + + const app = createApp(); + const handler = toWebHandler(app); + app.use(clerkMiddleware()); + app.use( + '/', + eventHandler(event => event.context.auth({ acceptsToken: 'api_key' })), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(MACHINE_AUTH_RESPONSE); + }); + + test('returns machine auth object when acceptsToken array includes machine token type', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + toAuth: () => MACHINE_AUTH_RESPONSE, + headers: new Headers(), + }); + + const app = createApp(); + const handler = toWebHandler(app); + app.use(clerkMiddleware()); + app.use( + '/', + eventHandler(event => event.context.auth({ acceptsToken: ['session_token', 'api_key'] })), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(MACHINE_AUTH_RESPONSE); + }); + + test('returns any auth object when acceptsToken is any', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + toAuth: () => MACHINE_AUTH_RESPONSE, + headers: new Headers(), + }); + + const app = createApp(); + const handler = toWebHandler(app); + app.use(clerkMiddleware()); + app.use( + '/', + eventHandler(event => event.context.auth({ acceptsToken: 'any' })), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(MACHINE_AUTH_RESPONSE); + }); + + test('returns unauthenticated machine object when token type does not match acceptsToken', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + toAuth: () => MACHINE_AUTH_RESPONSE, + headers: new Headers(), + }); + + const app = createApp(); + const handler = toWebHandler(app); + app.use(clerkMiddleware()); + app.use( + '/', + eventHandler(event => event.context.auth({ acceptsToken: 'machine_token' })), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + const result = await response.json(); + expect(result.tokenType).toBe('machine_token'); + expect(result.isAuthenticated).toBe(false); + expect(result.id).toBe(null); + }); + + test('returns invalid token object when token type is not in acceptsToken array', async () => { + authenticateRequestMock.mockResolvedValueOnce({ + toAuth: () => MACHINE_AUTH_RESPONSE, + headers: new Headers(), + }); + + const app = createApp(); + const handler = toWebHandler(app); + app.use(clerkMiddleware()); + app.use( + '/', + eventHandler(event => event.context.auth({ acceptsToken: ['session_token', 'machine_token'] })), + ); + const response = await handler(new Request(new URL('/', 'http://localhost'))); + + expect(response.status).toBe(200); + const result = await response.json(); + expect(result.tokenType).toBe(null); + expect(result.isAuthenticated).toBe(false); + }); }); }); diff --git a/packages/nuxt/src/runtime/server/clerkMiddleware.ts b/packages/nuxt/src/runtime/server/clerkMiddleware.ts index d1775655a3e..9fd49724e57 100644 --- a/packages/nuxt/src/runtime/server/clerkMiddleware.ts +++ b/packages/nuxt/src/runtime/server/clerkMiddleware.ts @@ -1,11 +1,12 @@ import type { AuthenticateRequestOptions } from '@clerk/backend/internal'; -import { AuthStatus, constants } from '@clerk/backend/internal'; +import { AuthStatus, constants, getAuthObjectForAcceptedToken, TokenType } from '@clerk/backend/internal'; import { deprecated } from '@clerk/shared/deprecated'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import type { EventHandler } from 'h3'; import { createError, eventHandler, setResponseHeader } from 'h3'; import { clerkClient } from './clerkClient'; +import type { AuthFn, AuthOptions } from './types'; import { createInitialState, toWebRequest } from './utils'; function parseHandlerAndOptions(args: unknown[]) { @@ -81,7 +82,10 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { return eventHandler(async event => { const clerkRequest = toWebRequest(event); - const requestState = await clerkClient(event).authenticateRequest(clerkRequest, options); + const requestState = await clerkClient(event).authenticateRequest(clerkRequest, { + ...options, + acceptsToken: 'any', + }); const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { @@ -105,13 +109,18 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { } const authObject = requestState.toAuth(); - const authHandler = () => authObject; + const authHandler: AuthFn = ((options?: AuthOptions) => { + const acceptsToken = options?.acceptsToken ?? TokenType.SessionToken; + return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); + }) as AuthFn; - const auth = new Proxy(Object.assign(authHandler, authObject), { - get(target, prop: string, receiver) { + const auth = new Proxy(authHandler, { + get(target, prop, receiver) { deprecated('event.context.auth', 'Use `event.context.auth()` as a function instead.'); - - return Reflect.get(target, prop, receiver); + // If the property exists on the function, return it + if (prop in target) return Reflect.get(target, prop, receiver); + // Otherwise, get it from the authObject + return authObject?.[prop as keyof typeof authObject]; }, }); diff --git a/packages/nuxt/src/runtime/server/getAuth.ts b/packages/nuxt/src/runtime/server/getAuth.ts index c62d43ce5c1..7b7e0d49629 100644 --- a/packages/nuxt/src/runtime/server/getAuth.ts +++ b/packages/nuxt/src/runtime/server/getAuth.ts @@ -1,9 +1,15 @@ -import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import type { SessionAuthObject } from '@clerk/backend'; +import { deprecated } from '@clerk/shared/deprecated'; import type { H3Event } from 'h3'; import { moduleRegistrationRequired } from './errors'; -export function getAuth(event: H3Event): SignedInAuthObject | SignedOutAuthObject { +/** + * @deprecated Use `event.context.auth()` instead. + */ +export function getAuth(event: H3Event): SessionAuthObject { + deprecated('getAuth', 'Use `event.context.auth()` instead.'); + const authObject = event.context.auth(); if (!authObject) { diff --git a/packages/nuxt/src/runtime/server/index.ts b/packages/nuxt/src/runtime/server/index.ts index 83788296a35..99477922906 100644 --- a/packages/nuxt/src/runtime/server/index.ts +++ b/packages/nuxt/src/runtime/server/index.ts @@ -3,3 +3,4 @@ export { clerkClient } from './clerkClient'; export { clerkMiddleware } from './clerkMiddleware'; export { createRouteMatcher } from './routeMatcher'; export { getAuth } from './getAuth'; +export type { AuthFn } from './types'; diff --git a/packages/nuxt/src/runtime/server/types.ts b/packages/nuxt/src/runtime/server/types.ts new file mode 100644 index 00000000000..f44e68ce892 --- /dev/null +++ b/packages/nuxt/src/runtime/server/types.ts @@ -0,0 +1,45 @@ +import type { AuthObject, InvalidTokenAuthObject, MachineAuthObject, SessionAuthObject } from '@clerk/backend'; +import type { + AuthenticateRequestOptions, + InferAuthObjectFromToken, + InferAuthObjectFromTokenArray, + SessionTokenType, + TokenType, +} from '@clerk/backend/internal'; + +export type AuthOptions = { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; + +/** + * @internal This type is used to define the `auth` function in the event context. + */ +export interface AuthFn { + /** + * @example + * const auth = event.context.auth({ acceptsToken: ['session_token', 'api_key'] }) + */ + ( + options: AuthOptions & { acceptsToken: T }, + ): + | InferAuthObjectFromTokenArray>> + | InvalidTokenAuthObject; + + /** + * @example + * const auth = event.context.auth({ acceptsToken: 'session_token' }) + */ + ( + options: AuthOptions & { acceptsToken: T }, + ): InferAuthObjectFromToken>>; + + /** + * @example + * const auth = event.context.auth({ acceptsToken: 'any' }) + */ + (options: AuthOptions & { acceptsToken: 'any' }): AuthObject; + + /** + * @example + * const auth = event.context.auth() + */ + (): SessionAuthObject; +} diff --git a/packages/nuxt/src/runtime/server/utils.ts b/packages/nuxt/src/runtime/server/utils.ts index 41aaf18a2ad..efc87424bde 100644 --- a/packages/nuxt/src/runtime/server/utils.ts +++ b/packages/nuxt/src/runtime/server/utils.ts @@ -1,4 +1,4 @@ -import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import type { AuthObject } from '@clerk/backend'; import { makeAuthObjectSerializable, stripPrivateDataFromObject } from '@clerk/backend/internal'; import type { InitialState } from '@clerk/types'; import type { H3Event } from 'h3'; @@ -17,7 +17,7 @@ export function toWebRequest(event: H3Event) { }); } -export function createInitialState(auth: SignedInAuthObject | SignedOutAuthObject) { +export function createInitialState(auth: AuthObject) { const initialState = makeAuthObjectSerializable(stripPrivateDataFromObject(auth)); return initialState as unknown as InitialState; } diff --git a/packages/nuxt/vitest.config.ts b/packages/nuxt/vitest.config.ts index 7382f40e7d2..0183bf23242 100644 --- a/packages/nuxt/vitest.config.ts +++ b/packages/nuxt/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + typecheck: { + enabled: true, + }, globals: true, }, });