Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/open-swans-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/backend': patch
'@clerk/nextjs': patch
---

Respect `acceptsToken` when returning unauthenticated session or machine object.
3 changes: 2 additions & 1 deletion packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ describe('subpath /internal exports', () => {
"getAuthObjectForAcceptedToken",
"getAuthObjectFromJwt",
"getMachineTokenType",
"isMachineToken",
"isMachineTokenByPrefix",
"isMachineTokenType",
"isTokenTypeAccepted",
"makeAuthObjectSerializable",
"reverificationError",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,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';
39 changes: 39 additions & 0 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest';

import { mockTokens, mockVerificationResults } from '../../fixtures/machine';
import type { AuthenticateContext } from '../authenticateContext';
import type { SignedOutAuthObject, UnauthenticatedMachineObject } from '../authObjects';
import {
authenticatedMachineObject,
getAuthObjectForAcceptedToken,
makeAuthObjectSerializable,
signedInAuthObject,
signedOutAuthObject,
Expand Down Expand Up @@ -387,3 +389,40 @@ 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 unauthenticated machine object for parsed type if acceptsToken is array and token type does not match', () => {
const result = getAuthObjectForAcceptedToken({
authObject: machineAuth,
acceptsToken: ['machine_token', 'oauth_token'],
});
expect((result as UnauthenticatedMachineObject<'api_key'>).tokenType).toBe('api_key');
expect((result as UnauthenticatedMachineObject<'api_key'>).id).toBeNull();
});

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 SignedOutAuthObject).tokenType).toBe('session_token');
expect((result as SignedOutAuthObject).userId).toBeNull();
});

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();
});
});
29 changes: 21 additions & 8 deletions packages/backend/src/tokens/__tests__/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,34 @@ import { describe, expect, it } from 'vitest';
import {
API_KEY_PREFIX,
getMachineTokenType,
isMachineToken,
isMachineTokenByPrefix,
isMachineTokenType,
isTokenTypeAccepted,
M2M_TOKEN_PREFIX,
OAUTH_TOKEN_PREFIX,
} from '../machine';

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);
});
});

Expand Down Expand Up @@ -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);
});
});
57 changes: 20 additions & 37 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -394,42 +395,13 @@ export const getAuthObjectFromJwt = (

/**
* @internal
* Filters and coerces an AuthObject based on the accepted token type(s).
* Returns an auth object matching the requested 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 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.
*
* - 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.
*
* @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)
*
* @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,
Expand All @@ -442,11 +414,22 @@ export function getAuthObjectForAcceptedToken({
return authObject;
}

if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) {
if (authObject.tokenType === TokenType.SessionToken) {
if (Array.isArray(acceptsToken)) {
if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) {
if (isMachineTokenType(authObject.tokenType)) {
return unauthenticatedMachineObject(authObject.tokenType, authObject.debug);
}
return signedOutAuthObject(authObject.debug);
}
return unauthenticatedMachineObject(authObject.tokenType, authObject.debug);
return authObject;
}
Comment thread
wobsoriano marked this conversation as resolved.
Comment thread
wobsoriano marked this conversation as resolved.

// Single value: Intent based
if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) {
if (isMachineTokenType(acceptsToken)) {
return unauthenticatedMachineObject(acceptsToken, authObject.debug);
}
return signedOutAuthObject(authObject.debug);
}
Comment thread
wobsoriano marked this conversation as resolved.

return authObject;
Expand Down
12 changes: 11 additions & 1 deletion packages/backend/src/tokens/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down Expand Up @@ -67,3 +67,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;
}
6 changes: 3 additions & 3 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/express/src/__tests__/getAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('getAuth', () => {
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();
Expand Down
2 changes: 1 addition & 1 deletion packages/fastify/src/__tests__/getAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('getAuth(req)', () => {
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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 unauthenticated machine object for the actual parsed machine token type 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).toBe('api_key');
expect((auth as AuthenticatedMachineObject<'api_key'>).userId).toBeNull();
});

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 () => {
Expand All @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
constants,
createClerkRequest,
createRedirect,
isMachineToken,
isMachineTokenByPrefix,
isTokenTypeAccepted,
signedOutAuthObject,
TokenType,
Expand Down Expand Up @@ -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 `<ClerkProvider/>` 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',
Expand Down
Loading
Loading