diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 7dcbdf03093..ec4dc55cf16 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,13 +1,23 @@ -import type { RedirectFun, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; -import { constants, createClerkRequest, createRedirect } from '@clerk/backend/internal'; +import type { AuthObject } from '@clerk/backend'; +import type { + AuthenticatedMachineObject, + AuthenticateRequestOptions, + RedirectFun, + SignedInAuthObject, + SignedOutAuthObject, + UnauthenticatedMachineObject, +} from '@clerk/backend/internal'; +import { constants, createClerkRequest, createRedirect, TokenType } from '@clerk/backend/internal'; import { notFound, redirect } from 'next/navigation'; import { PUBLISHABLE_KEY, SIGN_IN_URL, SIGN_UP_URL } from '../../server/constants'; import { createAsyncGetAuth } from '../../server/createGetAuth'; import { authAuthHeaderMissing } from '../../server/errors'; import { getAuthKeyFromRequest, getHeader } from '../../server/headers-utils'; +import { unauthorized } from '../../server/nextErrors'; import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; +import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray } from '../../server/types'; import { decryptClerkRequestData } from '../../server/utils'; import { isNextWithUnstableServerActions } from '../../utils/sdk-versions'; import { buildRequestLike } from './utils'; @@ -15,7 +25,7 @@ import { buildRequestLike } from './utils'; /** * `Auth` object of the currently active user and the `redirectToSignIn()` method. */ -type Auth = (SignedInAuthObject | SignedOutAuthObject) & { +type SessionAuth = (SignedInAuthObject | SignedOutAuthObject) & { /** * The `auth()` helper returns the `redirectToSignIn()` method, which you can use to redirect the user to the sign-in page. * @@ -24,7 +34,7 @@ type Auth = (SignedInAuthObject | SignedOutAuthObject) & { * > [!NOTE] * > `auth()` on the server-side can only access redirect URLs defined via [environment variables](https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects) or [`clerkMiddleware` dynamic keys](https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys). */ - redirectToSignIn: RedirectFun>; + redirectToSignIn: RedirectFun; /** * The `auth()` helper returns the `redirectToSignUp()` method, which you can use to redirect the user to the sign-up page. @@ -34,11 +44,44 @@ type Auth = (SignedInAuthObject | SignedOutAuthObject) & { * > [!NOTE] * > `auth()` on the server-side can only access redirect URLs defined via [environment variables](https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects) or [`clerkMiddleware` dynamic keys](https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys). */ - redirectToSignUp: RedirectFun>; + redirectToSignUp: RedirectFun; }; -export interface AuthFn { - (): Promise; +// Machine token auth objects +type MachineAuth = (AuthenticatedMachineObject | UnauthenticatedMachineObject) & { + tokenType: T; +}; + +export type AuthOptions = { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; + +export interface AuthFn> { + /** + * @example + * const authObject = await auth({ acceptsToken: ['session_token', 'api_key'] }) + */ + ( + options: AuthOptions & { acceptsToken: T }, + ): Promise, MachineAuth>>; + + /** + * @example + * const authObject = await auth({ acceptsToken: 'session_token' }) + */ + ( + options: AuthOptions & { acceptsToken: T }, + ): Promise, MachineAuth>>; + + /** + * @example + * const authObject = await auth({ acceptsToken: 'any' }) + */ + (options: AuthOptions & { acceptsToken: 'any' }): Promise; + + /** + * @example + * const authObject = await auth() + */ + (): Promise>; /** * `auth` includes a single property, the `protect()` method, which you can use in two ways: @@ -68,7 +111,7 @@ export interface AuthFn { * - Only works on the server-side, such as in Server Components, Route Handlers, and Server Actions. * - Requires [`clerkMiddleware()`](https://clerk.com/docs/references/nextjs/clerk-middleware) to be configured. */ -export const auth: AuthFn = (async () => { +export const auth: AuthFn = (async (options?: AuthOptions) => { // eslint-disable-next-line @typescript-eslint/no-require-imports require('server-only'); @@ -89,6 +132,9 @@ export const auth: AuthFn = (async () => { const authObject = await createAsyncGetAuth({ debugLoggerName: 'auth()', noAuthStatusMessage: authAuthHeaderMissing('auth', await stepsBasedOnSrcDirectory()), + options: { + acceptsToken: options?.acceptsToken ?? TokenType.SessionToken, + }, })(request); const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl'); @@ -110,8 +156,7 @@ export const auth: AuthFn = (async () => { publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY, signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL, signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL, - // TODO: Handle machine auth object - sessionStatus: authObject.tokenType === 'session_token' ? authObject.sessionStatus : null, + sessionStatus: authObject.tokenType === TokenType.SessionToken ? authObject.sessionStatus : null, }), returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(), ] as const; @@ -147,6 +192,7 @@ auth.protect = async (...args: any[]) => { redirectToSignIn: authObject.redirectToSignIn, notFound, redirect, + unauthorized, }); return protect(...args); diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 11c0fe1a3a1..238a77e7cdc 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1,6 +1,6 @@ // There is no need to execute the complete authenticateRequest to test clerkMiddleware // This mock SHOULD exist before the import of authenticateRequest -import { AuthStatus, constants } from '@clerk/backend/internal'; +import { AuthStatus, constants, TokenType } from '@clerk/backend/internal'; // used to assert the mock import assert from 'assert'; import type { NextFetchEvent } from 'next/server'; @@ -17,6 +17,7 @@ vi.mock('../clerkClient'); const publishableKey = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; const authenticateRequestMock = vi.fn().mockResolvedValue({ toAuth: () => ({ + tokenType: TokenType.SessionToken, debug: (d: any) => d, }), headers: new Headers(), @@ -107,6 +108,13 @@ describe('ClerkMiddleware type tests', () => { }); }); + it('can be used with a handler that expects a token type', () => { + clerkMiddlewareMock(async auth => { + const { getToken } = await auth({ acceptsToken: TokenType.ApiKey }); + await getToken(); + }); + }); + it('can be used with just an optional options object', () => { clerkMiddlewareMock({ secretKey: '', publishableKey: '' }); clerkMiddlewareMock(); @@ -391,6 +399,7 @@ describe('clerkMiddleware(params)', () => { vi.mocked(clerkClient).mockResolvedValue({ authenticateRequest: vi.fn().mockResolvedValue({ toAuth: () => ({ + tokenType: TokenType.SessionToken, debug: (d: any) => d, }), headers: new Headers(), @@ -429,7 +438,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -452,7 +461,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedIn, headers: new Headers(), - toAuth: () => ({ userId: 'user-id' }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'user-id' }), }); const resp = await clerkMiddleware(async auth => { @@ -464,6 +473,31 @@ describe('clerkMiddleware(params)', () => { expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + it('does not throw when protect is called and the request is authenticated with a machine token', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'api_key_xxxxxxxxxxxxxxxxxx' }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(200); + expect(resp?.headers.get('location')).toBeFalsy(); + expect(resp?.headers.get('WWW-Authenticate')).toBeFalsy(); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + it('throws a not found error when protect is called, the user is signed out, and is not a page request', async () => { const req = mockRequest({ url: '/protected', @@ -475,7 +509,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -487,6 +521,62 @@ describe('clerkMiddleware(params)', () => { expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + it('throws an unauthorized error when protect is called and the machine auth token is invalid', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ + tokenType: TokenType.ApiKey, + id: null, + }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect(resp?.headers.get('WWW-Authenticate')).toBeFalsy(); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('throws an unauthorized error with WWW-Authenticate header when protect is called and the oauth token is invalid', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer oauth_token_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ + tokenType: TokenType.OAuthToken, + id: null, + }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect(resp?.headers.get('WWW-Authenticate')).toBe( + 'Bearer resource_metadata="https://clerk.included.katydid-92.lcl.dev/.well-known/oauth-protected-resource"', + ); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + it('throws a not found error when protect is called with RBAC params the user does not fulfill, and is a page request', async () => { const req = mockRequest({ url: '/protected', @@ -498,7 +588,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedIn, headers: new Headers(), - toAuth: () => ({ userId: 'user-id', has: () => false }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'user-id', has: () => false }), }); const resp = await clerkMiddleware(async auth => { @@ -521,7 +611,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -545,7 +635,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedIn, headers: new Headers(), - toAuth: () => ({ userId: 'user-id', has: () => false }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'user-id', has: () => false }), }); const resp = await clerkMiddleware(async auth => { @@ -563,6 +653,76 @@ describe('clerkMiddleware(params)', () => { expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true'); expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + + it('throws an unauthorized error when protect is called with mismatching token types', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer m2m_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.MachineToken, id: null }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('does not throw when protect is called with array of token types and request matches one', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'api_key_xxxxxxxxxxxxxxxxxx' }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: [TokenType.SessionToken, TokenType.ApiKey] }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(200); + expect(resp?.headers.get('location')).toBeFalsy(); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('throws a not found error when protect is called with array of token types and request does not match any', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.ApiKey, id: null }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: [TokenType.SessionToken, TokenType.MachineToken] }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); }); describe('auth().redirectToSignIn()', () => { @@ -577,7 +737,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -603,7 +763,7 @@ describe('clerkMiddleware(params)', () => { 'Set-Cookie': 'session=;', 'X-Clerk-Auth': '1', }), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -628,7 +788,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -655,7 +815,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: 'userId', has: () => false }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'userId', has: () => false }), }); const resp = await clerkMiddleware(async auth => { @@ -714,7 +874,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -739,7 +899,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -764,7 +924,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(() => { diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts new file mode 100644 index 00000000000..ad600ea6135 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -0,0 +1,101 @@ +import type { AuthenticatedMachineObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import { constants, verifyMachineAuthToken } from '@clerk/backend/internal'; +import { NextRequest } from 'next/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { getAuthDataFromRequestAsync, getAuthDataFromRequestSync } from '../data/getAuthDataFromRequest'; + +vi.mock('@clerk/backend/internal', async () => { + const actual = await vi.importActual('@clerk/backend/internal'); + return { + ...actual, + verifyMachineAuthToken: vi.fn(), + }; +}); + +type MockRequestParams = { + url: string; + appendDevBrowserCookie?: boolean; + method?: string; + headers?: any; +}; + +const mockRequest = (params: MockRequestParams) => { + const { url, appendDevBrowserCookie = false, method = 'GET', headers = new Headers() } = params; + const headersWithCookie = new Headers(headers); + if (appendDevBrowserCookie) { + headersWithCookie.append('cookie', '__clerk_db_jwt=test_jwt'); + } + return new NextRequest(new URL(url, 'https://www.clerk.com').toString(), { method, headers: headersWithCookie }); +}; + +describe('getAuthDataFromRequestAsync', () => { + it('returns unauthenticated machine object when token type does not match', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer ak_xxx', + }), + }); + + const auth = await getAuthDataFromRequestAsync(req, { + acceptsToken: 'machine_token', + }); + + expect(auth.tokenType).toBe('api_key'); + expect((auth as AuthenticatedMachineObject).id).toBeNull(); + }); + + it('returns authenticated machine object when token type matches', async () => { + vi.mocked(verifyMachineAuthToken).mockResolvedValueOnce({ + data: { id: 'ak_123' } as any, + tokenType: 'api_key', + errors: undefined, + }); + + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer ak_xxx', + }), + }); + + const auth = await getAuthDataFromRequestAsync(req, { + acceptsToken: 'api_key', + }); + + expect(auth.tokenType).toBe('api_key'); + expect((auth as AuthenticatedMachineObject).id).toBe('ak_123'); + }); + + it('falls back to session token handling', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer session_xxx', + }), + }); + + const auth = await getAuthDataFromRequestAsync(req); + expect(auth.tokenType).toBe('session_token'); + expect((auth as SignedOutAuthObject).userId).toBeNull(); + }); +}); + +describe('getAuthDataFromRequestSync', () => { + it('only accepts session tokens', () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxx', + }), + }); + + const auth = getAuthDataFromRequestSync(req, { + acceptsToken: 'api_key', + }); + + expect(auth.tokenType).toBe('session_token'); + expect(auth.userId).toBeNull(); + }); +}); diff --git a/packages/nextjs/src/server/buildClerkProps.ts b/packages/nextjs/src/server/buildClerkProps.ts index cfbd4686d23..a8da66f78e6 100644 --- a/packages/nextjs/src/server/buildClerkProps.ts +++ b/packages/nextjs/src/server/buildClerkProps.ts @@ -1,7 +1,7 @@ import type { AuthObject, Organization, Session, User } from '@clerk/backend'; import { makeAuthObjectSerializable, stripPrivateDataFromObject } from '@clerk/backend/internal'; -import { getAuthDataFromRequest } from './data/getAuthDataFromRequest'; +import { getAuthDataFromRequestSync } from './data/getAuthDataFromRequest'; import type { RequestLike } from './types'; type BuildClerkPropsInitState = { user?: User | null; session?: Session | null; organization?: Organization | null }; @@ -59,7 +59,7 @@ export const buildClerkProps: BuildClerkProps = (req, initialState = {}) => { }; export function getDynamicAuthData(req: RequestLike, initialState = {}) { - const authObject = getAuthDataFromRequest(req); + const authObject = getAuthDataFromRequestSync(req); return makeAuthObjectSerializable(stripPrivateDataFromObject({ ...authObject, ...initialState })) as AuthObject; } diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index bd851453493..f8885937d19 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,11 +1,29 @@ import type { AuthObject, ClerkClient } from '@clerk/backend'; -import type { AuthenticateRequestOptions, ClerkRequest, RedirectFun, RequestState } from '@clerk/backend/internal'; -import { AuthStatus, constants, createClerkRequest, createRedirect } from '@clerk/backend/internal'; +import type { + AuthenticateRequestOptions, + ClerkRequest, + RedirectFun, + RequestState, + SignedInAuthObject, + SignedOutAuthObject, +} from '@clerk/backend/internal'; +import { + AuthStatus, + constants, + createClerkRequest, + createRedirect, + isTokenTypeAccepted, + signedOutAuthObject, + TokenType, + unauthenticatedMachineObject, +} from '@clerk/backend/internal'; import { parsePublishableKey } from '@clerk/shared/keys'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import type { AuthFn } from '../app-router/server/auth'; +import type { GetAuthOptions } from '../server/createGetAuth'; import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; import { withLogger } from '../utils/debugLogger'; import { canUseKeyless } from '../utils/feature-flags'; @@ -18,11 +36,13 @@ import { clerkMiddlewareRequestDataStorage, clerkMiddlewareRequestDataStore } fr import { isNextjsNotFoundError, isNextjsRedirectError, + isNextjsUnauthorizedError, isRedirectToSignInError, isRedirectToSignUpError, nextjsRedirectError, redirectToSignInError, redirectToSignUpError, + unauthorized, } from './nextErrors'; import type { AuthProtect } from './protect'; import { createProtect } from './protect'; @@ -35,16 +55,17 @@ import { setRequestHeadersOnNextResponse, } from './utils'; -export type ClerkMiddlewareAuthObject = AuthObject & { +export type ClerkMiddlewareSessionAuthObject = (SignedInAuthObject | SignedOutAuthObject) & { redirectToSignIn: RedirectFun; redirectToSignUp: RedirectFun; }; -export interface ClerkMiddlewareAuth { - (): Promise; +/** + * @deprecated Use `ClerkMiddlewareSessionAuthObject` instead. + */ +export type ClerkMiddlewareAuthObject = ClerkMiddlewareSessionAuthObject; - protect: AuthProtect; -} +export type ClerkMiddlewareAuth = AuthFn; type ClerkMiddlewareHandler = ( auth: ClerkMiddlewareAuth, @@ -52,11 +73,13 @@ type ClerkMiddlewareHandler = ( event: NextMiddlewareEvtParam, ) => NextMiddlewareReturn; +type AuthenticateAnyRequestOptions = Omit; + /** * The `clerkMiddleware()` function accepts an optional object. The following options are available. * @interface */ -export interface ClerkMiddlewareOptions extends AuthenticateRequestOptions { +export interface ClerkMiddlewareOptions extends AuthenticateAnyRequestOptions { /** * If true, additional debug information will be logged to the console. */ @@ -182,11 +205,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest); const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); - const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { - redirectToSignIn, - redirectToSignUp, - }); - const authHandler = () => Promise.resolve(authObjWithMethods); + const authHandler = createMiddlewareAuthHandler(authObject, redirectToSignIn, redirectToSignUp); authHandler.protect = protect; let handlerResult: Response = NextResponse.next(); @@ -331,12 +350,16 @@ export const createAuthenticateRequestOptions = ( return { ...options, ...handleMultiDomainAndProxy(clerkRequest, options), + // TODO: Leaving the acceptsToken as 'any' opens up the possibility of + // an economic attack. We should revisit this and only verify a token + // when auth() or auth.protect() is invoked. + acceptsToken: 'any', }; }; const createMiddlewareRedirectToSignIn = ( clerkRequest: ClerkRequest, -): ClerkMiddlewareAuthObject['redirectToSignIn'] => { +): ClerkMiddlewareSessionAuthObject['redirectToSignIn'] => { return (opts = {}) => { const url = clerkRequest.clerkUrl.toString(); redirectToSignInError(url, opts.returnBackUrl); @@ -345,7 +368,7 @@ const createMiddlewareRedirectToSignIn = ( const createMiddlewareRedirectToSignUp = ( clerkRequest: ClerkRequest, -): ClerkMiddlewareAuthObject['redirectToSignUp'] => { +): ClerkMiddlewareSessionAuthObject['redirectToSignUp'] => { return (opts = {}) => { const url = clerkRequest.clerkUrl.toString(); redirectToSignUpError(url, opts.returnBackUrl); @@ -365,10 +388,61 @@ const createMiddlewareProtect = ( redirectUrl: url, }); - return createProtect({ request: clerkRequest, redirect, notFound, authObject, redirectToSignIn })(params, options); + return createProtect({ + request: clerkRequest, + redirect, + notFound, + unauthorized, + authObject, + redirectToSignIn, + })(params, options); }) as unknown as Promise; }; +/** + * Modifies the auth object based on the token type. + * - For session tokens: adds redirect functions to the auth object + * - For machine tokens: validates token type and returns appropriate auth object + */ +const createMiddlewareAuthHandler = ( + authObject: AuthObject, + redirectToSignIn: RedirectFun, + redirectToSignUp: RedirectFun, +): ClerkMiddlewareAuth => { + const authObjWithMethods = Object.assign( + authObject, + authObject.tokenType === TokenType.SessionToken + ? { + redirectToSignIn, + redirectToSignUp, + } + : {}, + ); + + const authHandler = async (options?: GetAuthOptions) => { + const acceptsToken = options?.acceptsToken ?? TokenType.SessionToken; + + if (acceptsToken === 'any') { + return authObjWithMethods; + } + + if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { + if (authObject.tokenType === TokenType.SessionToken) { + return { + ...signedOutAuthObject(), + redirectToSignIn, + redirectToSignUp, + }; + } + return unauthenticatedMachineObject(authObject.tokenType); + } + + return authObjWithMethods; + }; + + return authHandler as ClerkMiddlewareAuth; +}; + // Handle errors thrown by protect() and redirectToSignIn() calls, // as we want to align the APIs between middleware, pages and route handlers // Normally, middleware requires to explicitly return a response, but we want to @@ -382,6 +456,27 @@ const handleControlFlowErrors = ( nextRequest: NextRequest, requestState: RequestState, ): Response => { + if (isNextjsUnauthorizedError(e)) { + const response = NextResponse.next({ status: 401 }); + + // RequestState.toAuth() returns a session_token type by default. + // We need to cast it to the correct type to check for OAuth tokens. + const authObject = (requestState as RequestState).toAuth(); + if (authObject && authObject.tokenType === TokenType.OAuthToken) { + // Following MCP spec, we return WWW-Authenticate header on 401 responses + // to enable OAuth 2.0 authorization server discovery (RFC9728). + // See https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location + const publishableKey = parsePublishableKey(requestState.publishableKey); + return setHeader( + response, + 'WWW-Authenticate', + `Bearer resource_metadata="https://${publishableKey?.frontendApi}/.well-known/oauth-protected-resource"`, + ); + } + + return response; + } + if (isNextjsNotFoundError(e)) { // Rewrite to a bogus URL to force not found error return setHeader( diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts index 56edce21415..93f448eeeb4 100644 --- a/packages/nextjs/src/server/createGetAuth.ts +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -1,15 +1,23 @@ import type { AuthObject } from '@clerk/backend'; -import { constants } from '@clerk/backend/internal'; +import { constants, type SignedInAuthObject, type SignedOutAuthObject } from '@clerk/backend/internal'; import { isTruthy } from '@clerk/shared/underscore'; import { withLogger } from '../utils/debugLogger'; import { isNextWithUnstableServerActions } from '../utils/sdk-versions'; -import { getAuthDataFromRequest } from './data/getAuthDataFromRequest'; +import type { GetAuthDataFromRequestOptions } from './data/getAuthDataFromRequest'; +import { + getAuthDataFromRequestAsync as getAuthDataFromRequestAsyncOriginal, + getAuthDataFromRequestSync as getAuthDataFromRequestSyncOriginal, +} from './data/getAuthDataFromRequest'; import { getAuthAuthHeaderMissing } from './errors'; import { detectClerkMiddleware, getHeader } from './headers-utils'; import type { RequestLike } from './types'; import { assertAuthStatus } from './utils'; +export type GetAuthOptions = { + acceptsToken?: GetAuthDataFromRequestOptions['acceptsToken']; +}; + /** * The async variant of our old `createGetAuth` allows for asynchronous code inside its callback. * Should be used with function like `auth()` that are already asynchronous. @@ -17,9 +25,11 @@ import { assertAuthStatus } from './utils'; export const createAsyncGetAuth = ({ debugLoggerName, noAuthStatusMessage, + options, }: { debugLoggerName: string; noAuthStatusMessage: string; + options?: GetAuthOptions; }) => withLogger(debugLoggerName, logger => { return async (req: RequestLike, opts?: { secretKey?: string }): Promise => { @@ -45,30 +55,41 @@ export const createAsyncGetAuth = ({ assertAuthStatus(req, noAuthStatusMessage); } - return getAuthDataFromRequest(req, { ...opts, logger }); + const getAuthDataFromRequestAsync = (req: RequestLike, opts: GetAuthDataFromRequestOptions = {}) => { + return getAuthDataFromRequestAsyncOriginal(req, { ...opts, logger, acceptsToken: options?.acceptsToken }); + }; + + return getAuthDataFromRequestAsync(req, { ...opts, logger, acceptsToken: options?.acceptsToken }); }; }); /** * Previous known as `createGetAuth`. We needed to create a sync and async variant in order to allow for improvements * that required dynamic imports (using `require` would not work). - * It powers the synchronous top-level api `getAuh()`. + * It powers the synchronous top-level api `getAuth()`. */ export const createSyncGetAuth = ({ debugLoggerName, noAuthStatusMessage, + options, }: { debugLoggerName: string; noAuthStatusMessage: string; + options?: GetAuthOptions; }) => withLogger(debugLoggerName, logger => { - return (req: RequestLike, opts?: { secretKey?: string }): AuthObject => { + return (req: RequestLike, opts?: { secretKey?: string }): SignedInAuthObject | SignedOutAuthObject => { if (isTruthy(getHeader(req, constants.Headers.EnableDebug))) { logger.enable(); } assertAuthStatus(req, noAuthStatusMessage); - return getAuthDataFromRequest(req, { ...opts, logger }); + + const getAuthDataFromRequestSync = (req: RequestLike, opts: GetAuthDataFromRequestOptions = {}) => { + return getAuthDataFromRequestSyncOriginal(req, { ...opts, logger, acceptsToken: options?.acceptsToken }); + }; + + return getAuthDataFromRequestSync(req, { ...opts, logger, acceptsToken: options?.acceptsToken }); }; }); diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index c67ca14f59d..4edf6501bef 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -1,5 +1,18 @@ import type { AuthObject } from '@clerk/backend'; -import { AuthStatus, constants, signedInAuthObject, signedOutAuthObject } from '@clerk/backend/internal'; +import type { AuthenticateRequestOptions, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import { + authenticatedMachineObject, + AuthStatus, + constants, + getMachineTokenType, + isMachineToken, + isTokenTypeAccepted, + signedInAuthObject, + signedOutAuthObject, + TokenType, + unauthenticatedMachineObject, + verifyMachineAuthToken, +} from '@clerk/backend/internal'; import { decodeJwt } from '@clerk/backend/jwt'; import type { LoggerNoCommit } from '../../utils/debugLogger'; @@ -8,14 +21,16 @@ import { getAuthKeyFromRequest, getHeader } from '../headers-utils'; import type { RequestLike } from '../types'; import { assertTokenSignature, decryptClerkRequestData } from '../utils'; -/** - * Given a request object, builds an auth object from the request data. Used in server-side environments to get access - * to auth data for a given request. - */ -export function getAuthDataFromRequest( +export type GetAuthDataFromRequestOptions = { + secretKey?: string; + logger?: LoggerNoCommit; + acceptsToken?: AuthenticateRequestOptions['acceptsToken']; +}; + +export const getAuthDataFromRequestSync = ( req: RequestLike, - opts: { secretKey?: string; logger?: LoggerNoCommit } = {}, -): AuthObject { + opts: GetAuthDataFromRequestOptions = {}, +): SignedInAuthObject | SignedOutAuthObject => { const authStatus = getAuthKeyFromRequest(req, 'AuthStatus'); const authToken = getAuthKeyFromRequest(req, 'AuthToken'); const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); @@ -37,21 +52,60 @@ export function getAuthDataFromRequest( authReason, }; - opts.logger?.debug('auth options', options); + // Only accept session tokens in the synchronous version. + // Machine tokens are not supported in this function. Any machine token input will result in a signed-out state. + if (!isTokenTypeAccepted(TokenType.SessionToken, opts.acceptsToken || TokenType.SessionToken)) { + return signedOutAuthObject(options); + } - let authObject; if (!authStatus || authStatus !== AuthStatus.SignedIn) { - authObject = signedOutAuthObject(options); - } else { - assertTokenSignature(authToken as string, options.secretKey, authSignature); + return signedOutAuthObject(options); + } + + assertTokenSignature(authToken as string, options.secretKey, authSignature); + const jwt = decodeJwt(authToken as string); + opts.logger?.debug('jwt', jwt.raw); + + // @ts-expect-error -- Restrict parameter type of options to only list what's needed + return signedInAuthObject(options, jwt.raw.text, jwt.payload); +}; + +/** + * Note: We intentionally avoid using interface/function overloads here since these functions + * are used internally. The complex type overloads are more valuable at the public API level + * (like in auth.protect(), auth()) where users interact directly with the types. + * + * Given a request object, builds an auth object from the request data. Used in server-side environments to get access + * to auth data for a given request. + */ +export const getAuthDataFromRequestAsync = async ( + req: RequestLike, + opts: GetAuthDataFromRequestOptions = {}, +): Promise => { + const bearerToken = getHeader(req, constants.Headers.Authorization)?.replace('Bearer ', ''); + const acceptsToken = opts.acceptsToken || TokenType.SessionToken; + + if (bearerToken && isMachineToken(bearerToken)) { + const tokenType = getMachineTokenType(bearerToken); + + if (!isTokenTypeAccepted(tokenType, acceptsToken)) { + return unauthenticatedMachineObject(tokenType); + } - const jwt = decodeJwt(authToken as string); + const options = { + secretKey: opts?.secretKey || SECRET_KEY, + publishableKey: PUBLISHABLE_KEY, + apiUrl: API_URL, + }; - opts.logger?.debug('jwt', jwt.raw); + // TODO: Cache the result of verifyMachineAuthToken + const { data, errors } = await verifyMachineAuthToken(bearerToken, options); + if (errors) { + return unauthenticatedMachineObject(tokenType); + } - // @ts-expect-error -- Restrict parameter type of options to only list what's needed - authObject = signedInAuthObject(options, jwt.raw.text, jwt.payload); + return authenticatedMachineObject(tokenType, bearerToken, data); } - return authObject; -} + return getAuthDataFromRequestSync(req, opts); +}; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 93348d9d7f5..a2bdebbf685 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -44,7 +44,12 @@ export { buildClerkProps } from './buildClerkProps'; export { auth } from '../app-router/server/auth'; export { currentUser } from '../app-router/server/currentUser'; export { clerkMiddleware } from './clerkMiddleware'; -export type { ClerkMiddlewareAuth, ClerkMiddlewareAuthObject, ClerkMiddlewareOptions } from './clerkMiddleware'; +export type { + ClerkMiddlewareAuth, + ClerkMiddlewareSessionAuthObject, + ClerkMiddlewareAuthObject, + ClerkMiddlewareOptions, +} from './clerkMiddleware'; /** * Re-export resource types from @clerk/backend diff --git a/packages/nextjs/src/server/nextErrors.ts b/packages/nextjs/src/server/nextErrors.ts index d5277a7104f..fc205f7ac89 100644 --- a/packages/nextjs/src/server/nextErrors.ts +++ b/packages/nextjs/src/server/nextErrors.ts @@ -155,6 +155,20 @@ function isRedirectToSignUpError(error: unknown): error is RedirectError<{ retur return false; } +function isNextjsUnauthorizedError(error: unknown): error is HTTPAccessFallbackError { + return whichHTTPAccessFallbackError(error) === HTTPAccessErrorStatusCodes.UNAUTHORIZED; +} + +/** + * In-house implementation of experimental `unauthorized()` + * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unauthorized.ts + */ +function unauthorized(): never { + const error = new Error(HTTP_ERROR_FALLBACK_ERROR_CODE) as HTTPAccessFallbackError; + error.digest = `${HTTP_ERROR_FALLBACK_ERROR_CODE};${HTTPAccessErrorStatusCodes.UNAUTHORIZED}`; + throw error; +} + export { isNextjsNotFoundError, isLegacyNextjsNotFoundError, @@ -164,4 +178,6 @@ export { isNextjsRedirectError, isRedirectToSignInError, isRedirectToSignUpError, + isNextjsUnauthorizedError, + unauthorized, }; diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 4162a0aeb55..8dd50fb050e 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -1,6 +1,11 @@ import type { AuthObject } from '@clerk/backend'; -import type { RedirectFun, SignedInAuthObject } from '@clerk/backend/internal'; -import { constants } from '@clerk/backend/internal'; +import type { + AuthenticatedMachineObject, + AuthenticateRequestOptions, + RedirectFun, + SignedInAuthObject, +} from '@clerk/backend/internal'; +import { constants, isTokenTypeAccepted, TokenType } from '@clerk/backend/internal'; import type { CheckAuthorizationFromSessionClaims, CheckAuthorizationParamsFromSessionClaims, @@ -11,8 +16,13 @@ import type { import { constants as nextConstants } from '../constants'; import { isNextFetcher } from './nextFetcher'; +import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray } from './types'; type AuthProtectOptions = { + /** + * The token type to check. + */ + token?: AuthenticateRequestOptions['acceptsToken']; /** * The URL to redirect the user to if they are not authorized. */ @@ -27,16 +37,45 @@ type AuthProtectOptions = { * Throws a Nextjs notFound error if user is not authenticated or authorized. */ export interface AuthProtect { + /** + * @example + * auth.protect({ permission: 'org:admin:example1' }); + * auth.protect({ role: 'admin' }); + */

( params?: CheckAuthorizationParamsFromSessionClaims

, options?: AuthProtectOptions, ): Promise; + /** + * @example + * auth.protect(has => has({ permission: 'org:admin:example1' })); + */ ( params?: (has: CheckAuthorizationFromSessionClaims) => boolean, options?: AuthProtectOptions, ): Promise; + /** + * @example + * auth.protect({ token: 'session_token' }); + */ + ( + options?: AuthProtectOptions & { token: T }, + ): Promise>; + + /** + * @example + * auth.protect({ token: ['session_token', 'machine_token'] }); + */ + ( + options?: AuthProtectOptions & { token: T }, + ): Promise>; + + /** + * @example + * auth.protect(); + */ (options?: AuthProtectOptions): Promise; } @@ -53,6 +92,10 @@ export function createProtect(opts: { * see {@link notFound} above */ redirect: (url: string) => void; + /** + * For m2m requests, throws a 401 response + */ + unauthorized: () => void; /** * protect() in middleware redirects to signInUrl if signed out * protect() in pages throws a notFound error if signed out @@ -60,17 +103,13 @@ export function createProtect(opts: { */ redirectToSignIn: RedirectFun; }): AuthProtect { - const { redirectToSignIn, authObject, redirect, notFound, request } = opts; + const { redirectToSignIn, authObject, redirect, notFound, request, unauthorized } = opts; return (async (...args: any[]) => { - const optionValuesAsParam = args[0]?.unauthenticatedUrl || args[0]?.unauthorizedUrl; - const paramsOrFunction = optionValuesAsParam - ? undefined - : (args[0] as - | CheckAuthorizationParamsWithCustomPermissions - | ((has: CheckAuthorizationWithCustomPermissions) => boolean)); + const paramsOrFunction = getAuthorizationParams(args[0]); const unauthenticatedUrl = (args[0]?.unauthenticatedUrl || args[1]?.unauthenticatedUrl) as string | undefined; const unauthorizedUrl = (args[0]?.unauthorizedUrl || args[1]?.unauthorizedUrl) as string | undefined; + const requestedToken = args[0]?.token || args[1]?.token || TokenType.SessionToken; const handleUnauthenticated = () => { if (unauthenticatedUrl) { @@ -84,14 +123,28 @@ export function createProtect(opts: { }; const handleUnauthorized = () => { + // For machine tokens, return a 401 response + if (authObject.tokenType !== TokenType.SessionToken) { + return unauthorized(); + } + if (unauthorizedUrl) { return redirect(unauthorizedUrl); } return notFound(); }; - if (authObject.tokenType !== 'session_token') { - throw new Error('TODO: Handle machine auth object'); + if (!isTokenTypeAccepted(authObject.tokenType, requestedToken)) { + return handleUnauthorized(); + } + + if (authObject.tokenType !== TokenType.SessionToken) { + // For machine tokens, we only check if they're authenticated + // They don't have session status or organization permissions + if (!authObject.id) { + return handleUnauthorized(); + } + return authObject; } /** @@ -136,6 +189,27 @@ export function createProtect(opts: { }) as AuthProtect; } +const getAuthorizationParams = (arg: any) => { + if (!arg) { + return undefined; + } + + // Skip authorization check if the arg contains any of these options + if (arg.unauthenticatedUrl || arg.unauthorizedUrl || arg.token) { + return undefined; + } + + // Skip if it's just a token-only object + if (Object.keys(arg).length === 1 && 'token' in arg) { + return undefined; + } + + // Return the authorization params/function + return arg as + | CheckAuthorizationParamsWithCustomPermissions + | ((has: CheckAuthorizationWithCustomPermissions) => boolean); +}; + const isServerActionRequest = (req: Request) => { return ( !!req.headers.get(nextConstants.Headers.NextUrl) && diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index b1f15bc79fd..347827983ad 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -1,3 +1,5 @@ +import type { AuthObject } from '@clerk/backend'; +import type { SessionTokenType, TokenType } from '@clerk/backend/internal'; import type { IncomingMessage } from 'http'; import type { NextApiRequest } from 'next'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; @@ -11,3 +13,29 @@ export type RequestLike = NextRequest | NextApiRequest | GsspRequest; export type NextMiddlewareRequestParam = Parameters['0']; export type NextMiddlewareEvtParam = Parameters['1']; export type NextMiddlewareReturn = ReturnType; + +/** + * Infers auth object type from an array of token types. + * - Session token only -> SessionType + * - Mixed tokens -> SessionType | MachineType + * - Machine tokens only -> MachineType + */ +export type InferAuthObjectFromTokenArray< + T extends readonly TokenType[], + SessionType extends AuthObject, + MachineType extends AuthObject, +> = SessionTokenType extends T[number] + ? T[number] extends SessionTokenType + ? SessionType + : SessionType | (MachineType & { tokenType: T[number] }) + : MachineType & { tokenType: T[number] }; + +/** + * Infers auth object type from a single token type. + * Returns SessionType for session tokens, or MachineType for machine tokens. + */ +export type InferAuthObjectFromToken< + T extends TokenType, + SessionType extends AuthObject, + MachineType extends AuthObject, +> = T extends SessionTokenType ? SessionType : MachineType & { tokenType: T };