diff --git a/.changeset/open-swans-count.md b/.changeset/open-swans-count.md new file mode 100644 index 00000000000..bc87ae0de0f --- /dev/null +++ b/.changeset/open-swans-count.md @@ -0,0 +1,6 @@ +--- +'@clerk/backend': minor +'@clerk/nextjs': minor +--- + +Respect `acceptsToken` when returning unauthenticated session or machine object. diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index a9fa7faf256..c9709c61722 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -50,7 +50,9 @@ describe('subpath /internal exports', () => { "getAuthObjectForAcceptedToken", "getAuthObjectFromJwt", "getMachineTokenType", - "isMachineToken", + "invalidTokenAuthObject", + "isMachineTokenByPrefix", + "isMachineTokenType", "isTokenTypeAccepted", "makeAuthObjectSerializable", "reverificationError", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 2b33d227388..78ec3aab09a 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -161,5 +161,5 @@ export type { /** * Auth objects */ -export type { AuthObject } from './tokens/authObjects'; +export type { AuthObject, InvalidTokenAuthObject } from './tokens/authObjects'; export type { SessionAuthObject, MachineAuthObject } from './tokens/types'; diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index 4f176cc0489..614bd13443e 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -31,6 +31,7 @@ export { signedInAuthObject, authenticatedMachineObject, unauthenticatedMachineObject, + invalidTokenAuthObject, getAuthObjectFromJwt, getAuthObjectForAcceptedToken, } from './tokens/authObjects'; @@ -53,4 +54,4 @@ export { reverificationError, reverificationErrorResponse } from '@clerk/shared/ export { verifyMachineAuthToken } from './tokens/verify'; -export { isMachineToken, getMachineTokenType, isTokenTypeAccepted } from './tokens/machine'; +export { isMachineTokenByPrefix, isMachineTokenType, getMachineTokenType, isTokenTypeAccepted } from './tokens/machine'; diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index 55b162bc025..b521e82a8b4 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest'; import { mockTokens, mockVerificationResults } from '../../fixtures/machine'; import type { AuthenticateContext } from '../authenticateContext'; +import type { InvalidTokenAuthObject, UnauthenticatedMachineObject } from '../authObjects'; import { authenticatedMachineObject, + getAuthObjectForAcceptedToken, makeAuthObjectSerializable, signedInAuthObject, signedOutAuthObject, @@ -387,3 +389,46 @@ describe('unauthenticatedMachineObject', () => { expect(retrievedToken).toBeNull(); }); }); + +describe('getAuthObjectForAcceptedToken', () => { + const debugData = { foo: 'bar' }; + const sessionAuth = signedOutAuthObject(debugData); + const machineAuth = authenticatedMachineObject('api_key', 'ak_xxx', mockVerificationResults.api_key, debugData); + + it('returns original object if acceptsToken is "any"', () => { + const result = getAuthObjectForAcceptedToken({ authObject: machineAuth, acceptsToken: 'any' }); + expect(result).toBe(machineAuth); + }); + + it('returns original object if token type matches', () => { + const result = getAuthObjectForAcceptedToken({ authObject: machineAuth, acceptsToken: 'api_key' }); + expect(result).toBe(machineAuth); + }); + + it('returns InvalidTokenAuthObject if acceptsToken is array and token type does not match', () => { + const result = getAuthObjectForAcceptedToken({ + authObject: machineAuth, + acceptsToken: ['machine_token', 'oauth_token'], + }); + expect((result as InvalidTokenAuthObject).tokenType).toBeNull(); + expect((result as InvalidTokenAuthObject).isAuthenticated).toBe(false); + }); + + it('returns InvalidTokenAuthObject if parsed type is not a machine token and does not match any in acceptsToken array', () => { + const result = getAuthObjectForAcceptedToken({ authObject: sessionAuth, acceptsToken: ['api_key', 'oauth_token'] }); + expect((result as InvalidTokenAuthObject).tokenType).toBeNull(); + expect((result as InvalidTokenAuthObject).isAuthenticated).toBe(false); + }); + + it('returns signed-out session object if parsed type is not a machine token and does not match', () => { + const result = getAuthObjectForAcceptedToken({ authObject: sessionAuth, acceptsToken: ['api_key', 'oauth_token'] }); + expect((result as InvalidTokenAuthObject).tokenType).toBeNull(); + expect((result as InvalidTokenAuthObject).isAuthenticated).toBe(false); + }); + + it('returns unauthenticated object for requested type if acceptsToken is a single value and does not match', () => { + const result = getAuthObjectForAcceptedToken({ authObject: machineAuth, acceptsToken: 'machine_token' }); + expect((result as UnauthenticatedMachineObject<'machine_token'>).tokenType).toBe('machine_token'); + expect((result as UnauthenticatedMachineObject<'machine_token'>).id).toBeNull(); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts index b0417b9b094..13e1a8873e7 100644 --- a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts +++ b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts @@ -1,6 +1,6 @@ import { expectTypeOf, test } from 'vitest'; -import type { AuthObject } from '../authObjects'; +import type { AuthObject, InvalidTokenAuthObject } from '../authObjects'; import type { GetAuthFn, MachineAuthObject, SessionAuthObject } from '../types'; // Across our SDKs, we have a getAuth() function @@ -22,10 +22,10 @@ test('infers the correct AuthObject type for each accepted token type', () => { // Array of token types expectTypeOf(getAuth(request, { acceptsToken: ['session_token', 'machine_token'] })).toMatchTypeOf< - SessionAuthObject | MachineAuthObject<'machine_token'> + SessionAuthObject | MachineAuthObject<'machine_token'> | InvalidTokenAuthObject >(); expectTypeOf(getAuth(request, { acceptsToken: ['machine_token', 'oauth_token'] })).toMatchTypeOf< - MachineAuthObject<'machine_token' | 'oauth_token'> + MachineAuthObject<'machine_token' | 'oauth_token'> | InvalidTokenAuthObject >(); // Any token type diff --git a/packages/backend/src/tokens/__tests__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts index dd84e40d3bb..fcff9e566b2 100644 --- a/packages/backend/src/tokens/__tests__/machine.test.ts +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'; import { API_KEY_PREFIX, getMachineTokenType, - isMachineToken, + isMachineTokenByPrefix, + isMachineTokenType, isTokenTypeAccepted, M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX, @@ -11,25 +12,25 @@ import { describe('isMachineToken', () => { it('returns true for tokens with M2M prefix', () => { - expect(isMachineToken(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe(true); + expect(isMachineTokenByPrefix(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe(true); }); it('returns true for tokens with OAuth prefix', () => { - expect(isMachineToken(`${OAUTH_TOKEN_PREFIX}some-token-value`)).toBe(true); + expect(isMachineTokenByPrefix(`${OAUTH_TOKEN_PREFIX}some-token-value`)).toBe(true); }); it('returns true for tokens with API key prefix', () => { - expect(isMachineToken(`${API_KEY_PREFIX}some-token-value`)).toBe(true); + expect(isMachineTokenByPrefix(`${API_KEY_PREFIX}some-token-value`)).toBe(true); }); it('returns false for tokens without a recognized prefix', () => { - expect(isMachineToken('unknown_prefix_token')).toBe(false); - expect(isMachineToken('session_token_value')).toBe(false); - expect(isMachineToken('jwt_token_value')).toBe(false); + expect(isMachineTokenByPrefix('unknown_prefix_token')).toBe(false); + expect(isMachineTokenByPrefix('session_token_value')).toBe(false); + expect(isMachineTokenByPrefix('jwt_token_value')).toBe(false); }); it('returns false for empty tokens', () => { - expect(isMachineToken('')).toBe(false); + expect(isMachineTokenByPrefix('')).toBe(false); }); }); @@ -78,3 +79,15 @@ describe('isTokenTypeAccepted', () => { expect(isTokenTypeAccepted('api_key', 'machine_token')).toBe(false); }); }); + +describe('isMachineTokenType', () => { + it('returns true for machine token types', () => { + expect(isMachineTokenType('api_key')).toBe(true); + expect(isMachineTokenType('machine_token')).toBe(true); + expect(isMachineTokenType('oauth_token')).toBe(true); + }); + + it('returns false for non-machine token types', () => { + expect(isMachineTokenType('session_token')).toBe(false); + }); +}); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index a876fddbd03..a1ec6892b0e 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -15,6 +15,7 @@ import type { APIKey, CreateBackendApiOptions, IdPOAuthAccessToken, MachineToken import { createBackendApiClient } from '../api'; import { isTokenTypeAccepted } from '../internal'; import type { AuthenticateContext } from './authenticateContext'; +import { isMachineTokenType } from './machine'; import type { MachineTokenType, SessionTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; import type { AuthenticateRequestOptions, MachineAuthType } from './types'; @@ -57,6 +58,7 @@ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { * Used to help debug issues when using Clerk in development. */ debug: AuthObjectDebug; + isAuthenticated: true; }; /** @@ -77,6 +79,7 @@ export type SignedOutAuthObject = { getToken: ServerGetToken; has: CheckAuthorizationFromSessionClaims; debug: AuthObjectDebug; + isAuthenticated: false; }; /** @@ -120,6 +123,7 @@ export type AuthenticatedMachineObject[T] : never; @@ -140,9 +144,18 @@ export type UnauthenticatedMachineObject[T] : never; +export type InvalidTokenAuthObject = { + isAuthenticated: false; + tokenType: null; + getToken: () => Promise; + has: () => false; + debug: AuthObjectDebug; +}; + /** * @interface */ @@ -150,7 +163,8 @@ export type AuthObject = | SignedInAuthObject | SignedOutAuthObject | AuthenticatedMachineObject - | UnauthenticatedMachineObject; + | UnauthenticatedMachineObject + | InvalidTokenAuthObject; const createDebug = (data: AuthObjectDebugData | undefined) => { return () => { @@ -200,6 +214,7 @@ export function signedInAuthObject( plans: (sessionClaims.pla as string) || '', }), debug: createDebug({ ...authenticateContext, sessionToken }), + isAuthenticated: true, }; } @@ -225,6 +240,7 @@ export function signedOutAuthObject( getToken: () => Promise.resolve(null), has: () => false, debug: createDebug(debugData), + isAuthenticated: false, }; } @@ -243,6 +259,7 @@ export function authenticatedMachineObject( getToken: () => Promise.resolve(token), has: () => false, debug: createDebug(debugData), + isAuthenticated: true, }; // Type assertions are safe here since we know the verification result type matches the tokenType. @@ -302,6 +319,7 @@ export function unauthenticatedMachineObject( has: () => false, getToken: () => Promise.resolve(null), debug: createDebug(debugData), + isAuthenticated: false, }; switch (tokenType) { @@ -340,6 +358,19 @@ export function unauthenticatedMachineObject( } } +/** + * @internal + */ +export function invalidTokenAuthObject(): InvalidTokenAuthObject { + return { + isAuthenticated: false, + tokenType: null, + getToken: () => Promise.resolve(null), + has: () => false, + debug: () => ({}), + }; +} + /** * Auth objects moving through the server -> client boundary need to be serializable * as we need to ensure that they can be transferred via the network as pure strings. @@ -394,42 +425,13 @@ export const getAuthObjectFromJwt = ( /** * @internal - * Filters and coerces an AuthObject based on the accepted token type(s). - * - * This function is used after authentication to ensure that the returned auth object - * matches the expected token type(s) specified by `acceptsToken`. If the token type - * of the provided `authObject` does not match any of the types in `acceptsToken`, - * it returns an unauthenticated or signed-out version of the object, depending on the token type. - * - * - If `acceptsToken` is `'any'`, the original auth object is returned. - * - If `acceptsToken` is a single token type or an array of token types, the function checks if - * `authObject.tokenType` matches any of them. - * - If the token type does not match and is a session token, a signed-out object is returned. - * - If the token type does not match and is a machine token, an unauthenticated machine object is returned. - * - If the token type matches, the original auth object is returned. - * - * @param {Object} params - * @param {AuthObject} params.authObject - The authenticated object to filter. - * @param {AuthenticateRequestOptions['acceptsToken']} [params.acceptsToken=TokenType.SessionToken] - The accepted token type(s). Can be a string, array of strings, or 'any'. - * @returns {AuthObject} The filtered or coerced auth object. + * Returns an auth object matching the requested token type(s). * - * @example - * // Accept only 'api_key' tokens - * const authObject = { tokenType: 'session_token', userId: 'user_123' }; - * const result = getAuthObjectForAcceptedToken({ authObject, acceptsToken: 'api_key' }); - * // result will be a signed-out object (since tokenType is 'session_token' and does not match) + * If the parsed token type does not match any in acceptsToken, returns: + * - an unauthenticated machine object for the first machine token type in acceptsToken (if present), or + * - a signed-out session object otherwise. * - * @example - * // Accept 'api_key' or 'machine_token' - * const authObject = { tokenType: 'machine_token', id: 'm2m_123' }; - * const result = getAuthObjectForAcceptedToken({ authObject, acceptsToken: ['api_key', 'machine_token'] }); - * // result will be the original authObject (since tokenType matches one in the array) - * - * @example - * // Accept any token type - * const authObject = { tokenType: 'api_key', id: 'ak_123' }; - * const result = getAuthObjectForAcceptedToken({ authObject, acceptsToken: 'any' }); - * // result will be the original authObject + * This ensures the returned object always matches the developer's intent. */ export function getAuthObjectForAcceptedToken({ authObject, @@ -442,11 +444,20 @@ export function getAuthObjectForAcceptedToken({ return authObject; } + if (Array.isArray(acceptsToken)) { + if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { + // If the token is not in the accepted array, return invalid token auth object + return invalidTokenAuthObject(); + } + return authObject; + } + + // Single value: Intent based if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { - if (authObject.tokenType === TokenType.SessionToken) { - return signedOutAuthObject(authObject.debug); + if (isMachineTokenType(acceptsToken)) { + return unauthenticatedMachineObject(acceptsToken, authObject.debug); } - return unauthenticatedMachineObject(authObject.tokenType, authObject.debug); + return signedOutAuthObject(authObject.debug); } return authObject; diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts index 33ad67e21f9..17df764a429 100644 --- a/packages/backend/src/tokens/machine.ts +++ b/packages/backend/src/tokens/machine.ts @@ -18,7 +18,7 @@ const MACHINE_TOKEN_PREFIXES = [M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX, API_KEY_PR * @param token - The token string to check * @returns true if the token starts with a recognized machine token prefix */ -export function isMachineToken(token: string): boolean { +export function isMachineTokenByPrefix(token: string): boolean { return MACHINE_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix)); } @@ -52,14 +52,18 @@ export function getMachineTokenType(token: string): MachineTokenType { /** * Check if a token type is accepted given a requested token type or list of token types. * - * @param tokenType - The token type to check + * @param tokenType - The token type to check (can be null if the token is invalid) * @param acceptsToken - The requested token type or list of token types * @returns true if the token type is accepted */ export const isTokenTypeAccepted = ( - tokenType: TokenType, + tokenType: TokenType | null, acceptsToken: NonNullable, ): boolean => { + if (!tokenType) { + return false; + } + if (acceptsToken === 'any') { return true; } @@ -67,3 +71,13 @@ export const isTokenTypeAccepted = ( const tokenTypes = Array.isArray(acceptsToken) ? acceptsToken : [acceptsToken]; return tokenTypes.includes(tokenType); }; + +/** + * Checks if a token type string is a machine token type (api_key, machine_token, or oauth_token). + * + * @param type - The token type string to check + * @returns true if the type is a machine token type + */ +export function isMachineTokenType(type: string): type is MachineTokenType { + return type === TokenType.ApiKey || type === TokenType.MachineToken || type === TokenType.OAuthToken; +} diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 8b9143c1487..9b7e5f6c12a 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -14,7 +14,7 @@ import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import { createClerkRequest } from './clerkRequest'; import { getCookieName, getCookieValue } from './cookie'; import { HandshakeService } from './handshake'; -import { getMachineTokenType, isMachineToken, isTokenTypeAccepted } from './machine'; +import { getMachineTokenType, isMachineTokenByPrefix, isTokenTypeAccepted } from './machine'; import { OrganizationMatcher } from './organizationMatcher'; import type { MachineTokenType, SessionTokenType } from './tokenTypes'; import { TokenType } from './tokenTypes'; @@ -653,7 +653,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( } // Handle case where tokenType is any and the token is not a machine token - if (!isMachineToken(tokenInHeader)) { + if (!isMachineTokenByPrefix(tokenInHeader)) { return signedOut({ tokenType: acceptsToken as MachineTokenType, authenticateContext, @@ -688,7 +688,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( } // Handle as a machine token - if (isMachineToken(tokenInHeader)) { + if (isMachineTokenByPrefix(tokenInHeader)) { const parsedTokenType = getMachineTokenType(tokenInHeader); const mismatchState = checkTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); if (mismatchState) { diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index badda7d18f3..2b95dfb6c23 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -5,6 +5,7 @@ import type { ApiClient, APIKey, IdPOAuthAccessToken, MachineToken } from '../ap import type { AuthenticatedMachineObject, AuthObject, + InvalidTokenAuthObject, SignedInAuthObject, SignedOutAuthObject, UnauthenticatedMachineObject, @@ -200,7 +201,8 @@ export interface GetAuthFn req: RequestType, options: AuthOptions & { acceptsToken: T }, ): MaybePromise< - InferAuthObjectFromTokenArray>>, + | InferAuthObjectFromTokenArray>> + | InvalidTokenAuthObject, ReturnsPromise >; diff --git a/packages/express/src/__tests__/getAuth.test.ts b/packages/express/src/__tests__/getAuth.test.ts index 7ce9acda9ee..750994777a4 100644 --- a/packages/express/src/__tests__/getAuth.test.ts +++ b/packages/express/src/__tests__/getAuth.test.ts @@ -1,3 +1,5 @@ +import type { AuthenticatedMachineObject } from '@clerk/backend/internal'; + import { getAuth } from '../getAuth'; import { mockRequest, mockRequestWithAuth } from './helpers'; @@ -33,14 +35,15 @@ describe('getAuth', () => { const req = mockRequestWithAuth({ tokenType: 'machine_token', id: 'm2m_1234' }); const result = getAuth(req, { acceptsToken: ['machine_token', 'api_key'] }); expect(result.tokenType).toBe('machine_token'); - expect(result.id).toBe('m2m_1234'); - expect(result.subject).toBeUndefined(); + + expect((result as AuthenticatedMachineObject<'machine_token'>).id).toBe('m2m_1234'); + expect((result as AuthenticatedMachineObject<'machine_token'>).subject).toBeUndefined(); }); it('returns an unauthenticated auth object when the tokenType does not match acceptsToken', () => { const req = mockRequestWithAuth({ tokenType: 'session_token', userId: 'user_12345' }); const result = getAuth(req, { acceptsToken: 'api_key' }); - expect(result.tokenType).toBe('session_token'); // reflects the actual token found + expect(result.tokenType).toBe('api_key'); // reflects the actual token found // Properties specific to authenticated objects should be null or undefined expect(result.userId).toBeNull(); expect(result.orgId).toBeNull(); diff --git a/packages/fastify/src/__tests__/getAuth.test.ts b/packages/fastify/src/__tests__/getAuth.test.ts index 0c9f94a56dd..9d5dfe8f660 100644 --- a/packages/fastify/src/__tests__/getAuth.test.ts +++ b/packages/fastify/src/__tests__/getAuth.test.ts @@ -1,3 +1,4 @@ +import type { AuthenticatedMachineObject } from '@clerk/backend/internal'; import type { FastifyRequest } from 'fastify'; import { getAuth } from '../getAuth'; @@ -30,14 +31,14 @@ describe('getAuth(req)', () => { const req = { auth: { tokenType: 'machine_token', id: 'm2m_1234' } } as unknown as FastifyRequest; const result = getAuth(req, { acceptsToken: ['machine_token', 'api_key'] }); expect(result.tokenType).toBe('machine_token'); - expect(result.id).toBe('m2m_1234'); - expect(result.subject).toBeUndefined(); + expect((result as AuthenticatedMachineObject<'machine_token'>).id).toBe('m2m_1234'); + expect((result as AuthenticatedMachineObject<'machine_token'>).subject).toBeUndefined(); }); it('returns an unauthenticated auth object when the tokenType does not match acceptsToken', () => { const req = { auth: { tokenType: 'session_token', userId: 'user_12345' } } as unknown as FastifyRequest; const result = getAuth(req, { acceptsToken: 'api_key' }); - expect(result.tokenType).toBe('session_token'); // reflects the actual token found + expect(result.tokenType).toBe('api_key'); // reflects the actual token found expect(result.userId).toBeNull(); expect(result.orgId).toBeNull(); }); diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index ec5b151f7ee..cd5ce02c81d 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,4 +1,4 @@ -import type { AuthObject, MachineAuthObject, SessionAuthObject } from '@clerk/backend'; +import type { AuthObject, InvalidTokenAuthObject, MachineAuthObject, SessionAuthObject } from '@clerk/backend'; import type { AuthenticateRequestOptions, InferAuthObjectFromToken, @@ -56,11 +56,12 @@ export interface AuthFn> { ( options: AuthOptions & { acceptsToken: T }, ): Promise< - InferAuthObjectFromTokenArray< - T, - SessionAuthWithRedirect, - MachineAuthObject> - > + | InferAuthObjectFromTokenArray< + T, + SessionAuthWithRedirect, + MachineAuthObject> + > + | InvalidTokenAuthObject >; /** diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts index 17a2d967a35..3f17613ba50 100644 --- a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -42,8 +42,46 @@ describe('getAuthDataFromRequestAsync', () => { acceptsToken: 'machine_token', }); + expect(auth.tokenType).toBe('machine_token'); + expect((auth as AuthenticatedMachineObject<'machine_token'>).machineId).toBeNull(); + }); + + it('returns invalid token auth object when token type does not match any in acceptsToken array', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer ak_xxx', + }), + }); + + const auth = await getAuthDataFromRequestAsync(req, { + acceptsToken: ['machine_token', 'oauth_token', 'session_token'], + }); + + expect(auth.tokenType).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + }); + + it('returns authenticated api_key object when array contains only api_key and token is ak_xxx and verification passes', async () => { + vi.mocked(verifyMachineAuthToken).mockResolvedValueOnce({ + data: { id: 'ak_123', subject: 'user_12345' } 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).toBeNull(); + expect((auth as AuthenticatedMachineObject<'api_key'>).id).toBe('ak_123'); }); it('returns authenticated machine object when token type matches', async () => { @@ -65,7 +103,7 @@ describe('getAuthDataFromRequestAsync', () => { }); expect(auth.tokenType).toBe('api_key'); - expect((auth as AuthenticatedMachineObject).id).toBe('ak_123'); + expect((auth as AuthenticatedMachineObject<'api_key'>).id).toBe('ak_123'); }); it('falls back to session token handling', async () => { diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 9dd32a2d49b..f698399410e 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -12,7 +12,7 @@ import { constants, createClerkRequest, createRedirect, - isMachineToken, + isMachineTokenByPrefix, isTokenTypeAccepted, signedOutAuthObject, TokenType, @@ -288,7 +288,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl /** * In keyless mode, if the publishable key is missing, let the request through, to render `` that will resume the flow gracefully. */ - if (isMissingPublishableKey && !isMachineToken(authHeader)) { + if (isMissingPublishableKey && !isMachineTokenByPrefix(authHeader)) { const res = NextResponse.next(); setRequestHeadersOnNextResponse(res, request, { [constants.Headers.AuthStatus]: 'signed-out', diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index d5cd3039ceb..6e7bfb40814 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -1,13 +1,18 @@ import type { AuthObject } from '@clerk/backend'; -import type { AuthenticateRequestOptions, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import { authenticatedMachineObject, + type AuthenticateRequestOptions, AuthStatus, constants, getAuthObjectFromJwt, getMachineTokenType, - isMachineToken, + invalidTokenAuthObject, + isMachineTokenByPrefix, + isMachineTokenType, isTokenTypeAccepted, + type MachineTokenType, + type SignedInAuthObject, + type SignedOutAuthObject, signedOutAuthObject, TokenType, unauthenticatedMachineObject, @@ -85,37 +90,58 @@ export const getAuthDataFromRequestAsync = async ( opts: GetAuthDataFromRequestOptions = {}, ): Promise => { const { authStatus, authMessage, authReason } = getAuthHeaders(req); - opts.logger?.debug('headers', { authStatus, authMessage, authReason }); const bearerToken = getHeader(req, constants.Headers.Authorization)?.replace('Bearer ', ''); const acceptsToken = opts.acceptsToken || TokenType.SessionToken; + const options = { + secretKey: opts?.secretKey || SECRET_KEY, + publishableKey: PUBLISHABLE_KEY, + apiUrl: API_URL, + authStatus, + authMessage, + authReason, + }; - if (bearerToken && isMachineToken(bearerToken)) { - const tokenType = getMachineTokenType(bearerToken); - - const options = { - secretKey: opts?.secretKey || SECRET_KEY, - publishableKey: PUBLISHABLE_KEY, - apiUrl: API_URL, - authStatus, - authMessage, - authReason, - }; - - if (!isTokenTypeAccepted(tokenType, acceptsToken)) { - return unauthenticatedMachineObject(tokenType, options); + if (bearerToken) { + const isMachine = isMachineTokenByPrefix(bearerToken); + const tokenType = isMachine ? getMachineTokenType(bearerToken) : undefined; + + if (Array.isArray(acceptsToken)) { + if (isMachine) { + return handleMachineToken({ + bearerToken, + tokenType: tokenType as MachineTokenType, + acceptsToken, + options, + }); + } + } else { + let intendedType: TokenType | undefined; + if (isMachineTokenType(acceptsToken)) { + intendedType = acceptsToken; + } + const result = await handleIntentBased({ + isMachine, + tokenType, + intendedType, + bearerToken, + acceptsToken, + options, + }); + if (result) { + return result; + } } + } - // TODO(Rob): Cache the result of verifyMachineAuthToken - const { data, errors } = await verifyMachineAuthToken(bearerToken, options); - if (errors) { - return unauthenticatedMachineObject(tokenType, options); + if (Array.isArray(acceptsToken)) { + if (!isTokenTypeAccepted(TokenType.SessionToken, acceptsToken)) { + return invalidTokenAuthObject(); } - - return authenticatedMachineObject(tokenType, bearerToken, data); } + // Fall through to session logic return getAuthDataFromRequestSync(req, opts); }; @@ -134,3 +160,76 @@ const getAuthHeaders = (req: RequestLike) => { authSignature, }; }; + +/** + * Handles verification and response shaping for machine tokens. + * Returns an authenticated or unauthenticated machine object based on verification and type acceptance. + */ +async function handleMachineToken({ + bearerToken, + tokenType, + acceptsToken, + options, +}: { + bearerToken: string; + tokenType: MachineTokenType; + acceptsToken: AuthenticateRequestOptions['acceptsToken']; + options: Record; +}) { + if (Array.isArray(acceptsToken)) { + // If the token is not in the accepted array, return invalid token auth object + if (!isTokenTypeAccepted(tokenType, acceptsToken)) { + return invalidTokenAuthObject(); + } + } + + if (!isTokenTypeAccepted(tokenType, acceptsToken ?? TokenType.SessionToken)) { + return unauthenticatedMachineObject(tokenType, options); + } + const { data, errors } = await verifyMachineAuthToken(bearerToken, options); + if (errors) { + return unauthenticatedMachineObject(tokenType, options); + } + return authenticatedMachineObject(tokenType, bearerToken, data); +} + +/** + * Handles intent-based fallback for single-value acceptsToken. + * Returns an unauthenticated object for the intended type, or falls back to session logic if not applicable. + */ +async function handleIntentBased({ + isMachine, + tokenType, + intendedType, + bearerToken, + acceptsToken, + options, +}: { + isMachine: boolean; + tokenType: TokenType | undefined; + intendedType: TokenType | undefined; + bearerToken: string; + acceptsToken: AuthenticateRequestOptions['acceptsToken']; + options: Record; +}) { + if (isMachine) { + if (!tokenType) { + return signedOutAuthObject(options); + } + if (!isTokenTypeAccepted(tokenType, acceptsToken ?? TokenType.SessionToken)) { + if (intendedType && isMachineTokenType(intendedType)) { + return unauthenticatedMachineObject(intendedType, options); + } + return signedOutAuthObject(options); + } + const { data, errors } = await verifyMachineAuthToken(bearerToken, options); + if (errors) { + return unauthenticatedMachineObject(tokenType as MachineTokenType, options); + } + return authenticatedMachineObject(tokenType as MachineTokenType, bearerToken, data); + } else if (intendedType && isMachineTokenType(intendedType)) { + return unauthenticatedMachineObject(intendedType, options); + } + // else: fall through to session logic + return null; +} diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index cfb0dea5628..f74918893a8 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -145,6 +145,10 @@ export function createProtect(opts: { return handleUnauthorized(); } + if (authObject.tokenType === null) { + 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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy