Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
8 changes: 8 additions & 0 deletions .changeset/twenty-beds-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/backend': minor
'@clerk/nextjs': minor
---

- Optimize `auth()` calls to avoid unnecessary verification calls when the provided token type is not in the `acceptsToken` array.
- Add handling for invalid token types when `acceptsToken` is an array in `authenticateRequyest()`: now returns a clear unauthenticated state (`tokenType: null`) if the token is not in the accepted list.

2 changes: 1 addition & 1 deletion packages/backend/src/tokens/__tests__/request.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ test('returns the correct `authenticateRequest()` return type for each accepted
// Array of token types
expectTypeOf(
authenticateRequest(request, { acceptsToken: ['session_token', 'api_key', 'machine_token'] }),
).toMatchTypeOf<Promise<RequestState<'session_token' | 'api_key' | 'machine_token'>>>();
).toMatchTypeOf<Promise<RequestState<'session_token' | 'api_key' | 'machine_token' | null>>>();

// Any token type
expectTypeOf(authenticateRequest(request, { acceptsToken: 'any' })).toMatchTypeOf<Promise<RequestState<TokenType>>>();
Expand Down
14 changes: 10 additions & 4 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { AuthReason } from '../authStatus';
import { AuthErrorReason, AuthStatus } from '../authStatus';
import { OrganizationMatcher } from '../organizationMatcher';
import { authenticateRequest, RefreshTokenErrorReason } from '../request';
import type { MachineTokenType } from '../tokenTypes';
import { type MachineTokenType, TokenType } from '../tokenTypes';
import type { AuthenticateRequestOptions } from '../types';

const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';
Expand Down Expand Up @@ -1203,7 +1203,7 @@ describe('tokens.authenticateRequest(options)', () => {
});

// Test each token type with parameterized tests
const tokenTypes = ['api_key', 'oauth_token', 'machine_token'] as const;
const tokenTypes = [TokenType.ApiKey, TokenType.OAuthToken, TokenType.MachineToken];

describe.each(tokenTypes)('%s Authentication', tokenType => {
const mockToken = mockTokens[tokenType];
Expand Down Expand Up @@ -1240,6 +1240,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType,
isAuthenticated: false,
});
});
});
Expand Down Expand Up @@ -1289,6 +1290,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'api_key',
isAuthenticated: false,
});
});

Expand All @@ -1303,6 +1305,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'oauth_token',
isAuthenticated: false,
});
});

Expand All @@ -1317,6 +1320,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'machine_token',
isAuthenticated: false,
});
});

Expand All @@ -1331,6 +1335,7 @@ describe('tokens.authenticateRequest(options)', () => {
});
expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'machine_token',
isAuthenticated: false,
});
});
});
Expand Down Expand Up @@ -1360,12 +1365,13 @@ describe('tokens.authenticateRequest(options)', () => {
);

expect(requestState).toBeMachineUnauthenticated({
tokenType: 'machine_token',
tokenType: null,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({
tokenType: 'machine_token',
tokenType: null,
isAuthenticated: false,
});
});
});
Expand Down
15 changes: 9 additions & 6 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,37 +428,40 @@ export const getAuthObjectFromJwt = (
* Returns an auth object matching the requested token type(s).
*
* 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
* - an invalid token auth object if the token is not in the accepted array
* - an unauthenticated machine object for machine tokens, or
* - a signed-out session object otherwise.
*
* This ensures the returned object always matches the developer's intent.
*/
export function getAuthObjectForAcceptedToken({
export const getAuthObjectForAcceptedToken = ({
authObject,
acceptsToken = TokenType.SessionToken,
}: {
authObject: AuthObject;
acceptsToken: AuthenticateRequestOptions['acceptsToken'];
}): AuthObject {
}): AuthObject => {
// 1. any token: return as-is
if (acceptsToken === 'any') {
return authObject;
}

// 2. array of tokens: must match one of the accepted types
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
// 3. single token: must match exactly, else return appropriate unauthenticated object
if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) {
if (isMachineTokenType(acceptsToken)) {
return unauthenticatedMachineObject(acceptsToken, authObject.debug);
}
return signedOutAuthObject(authObject.debug);
}

// 4. default: return as-is
return authObject;
}
};
47 changes: 37 additions & 10 deletions packages/backend/src/tokens/authStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import type { TokenVerificationErrorReason } from '../errors';
import type { AuthenticateContext } from './authenticateContext';
import type {
AuthenticatedMachineObject,
InvalidTokenAuthObject,
SignedInAuthObject,
SignedOutAuthObject,
UnauthenticatedMachineObject,
} from './authObjects';
import {
authenticatedMachineObject,
invalidTokenAuthObject,
signedInAuthObject,
signedOutAuthObject,
unauthenticatedMachineObject,
Expand All @@ -27,13 +29,15 @@ export const AuthStatus = {

export type AuthStatus = (typeof AuthStatus)[keyof typeof AuthStatus];

type ToAuth<T extends TokenType, Authenticated extends boolean> = T extends SessionTokenType
? Authenticated extends true
? (opts?: PendingSessionOptions) => SignedInAuthObject
: () => SignedOutAuthObject
: Authenticated extends true
? () => AuthenticatedMachineObject<Exclude<T, SessionTokenType>>
: () => UnauthenticatedMachineObject<Exclude<T, SessionTokenType>>;
type ToAuth<T extends TokenType | null, Authenticated extends boolean> = T extends null
? () => InvalidTokenAuthObject
: T extends SessionTokenType
? Authenticated extends true
? (opts?: PendingSessionOptions) => SignedInAuthObject
: () => SignedOutAuthObject
: Authenticated extends true
? () => AuthenticatedMachineObject<Exclude<T, SessionTokenType | null>>
: () => UnauthenticatedMachineObject<Exclude<T, SessionTokenType | null>>;

export type AuthenticatedState<T extends TokenType = SessionTokenType> = {
status: typeof AuthStatus.SignedIn;
Expand All @@ -58,7 +62,7 @@ export type AuthenticatedState<T extends TokenType = SessionTokenType> = {
toAuth: ToAuth<T, true>;
};

export type UnauthenticatedState<T extends TokenType = SessionTokenType> = {
export type UnauthenticatedState<T extends TokenType | null = SessionTokenType> = {
status: typeof AuthStatus.SignedOut;
reason: AuthReason;
message: string;
Expand Down Expand Up @@ -120,8 +124,8 @@ export type AuthErrorReason = (typeof AuthErrorReason)[keyof typeof AuthErrorRea

export type AuthReason = AuthErrorReason | TokenVerificationErrorReason;

export type RequestState<T extends TokenType = SessionTokenType> =
| AuthenticatedState<T>
export type RequestState<T extends TokenType | null = SessionTokenType> =
| AuthenticatedState<T extends null ? never : T>
| UnauthenticatedState<T>
| (T extends SessionTokenType ? HandshakeState : never);

Expand Down Expand Up @@ -240,6 +244,29 @@ export function handshake(
});
}

export function signedOutInvalidToken(): UnauthenticatedState<null> {
const authObject = invalidTokenAuthObject();
return {
status: AuthStatus.SignedOut,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
proxyUrl: '',
publishableKey: '',
isSatellite: false,
domain: '',
signInUrl: '',
signUpUrl: '',
afterSignInUrl: '',
afterSignUpUrl: '',
isSignedIn: false,
isAuthenticated: false,
tokenType: null,
toAuth: () => authObject,
headers: new Headers(),
token: null,
};
}

const withDebugHeaders = <T extends { headers: Headers; message?: string; reason?: AuthReason; status?: AuthStatus }>(
requestState: T,
): T => {
Expand Down
43 changes: 28 additions & 15 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { AuthenticateContext } from './authenticateContext';
import { createAuthenticateContext } from './authenticateContext';
import type { SignedInAuthObject } from './authObjects';
import type { HandshakeState, RequestState, SignedInState, SignedOutState, UnauthenticatedState } from './authStatus';
import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus';
import { AuthErrorReason, handshake, signedIn, signedOut, signedOutInvalidToken } from './authStatus';
import { createClerkRequest } from './clerkRequest';
import { getCookieName, getCookieValue } from './cookie';
import { HandshakeService } from './handshake';
Expand Down Expand Up @@ -88,6 +88,23 @@ function checkTokenTypeMismatch(
return null;
}

function isTokenTypeInAcceptedArray(
acceptsToken: ReadonlyArray<TokenType>,
authenticateContext: AuthenticateContext,
): boolean {
let parsedTokenType: TokenType | null = null;
const { tokenInHeader } = authenticateContext;
if (tokenInHeader) {
if (isMachineTokenByPrefix(tokenInHeader)) {
parsedTokenType = getMachineTokenType(tokenInHeader);
} else {
parsedTokenType = TokenType.SessionToken;
}
}
const typeToCheck = parsedTokenType ?? TokenType.SessionToken;
return isTokenTypeAccepted(typeToCheck, acceptsToken as TokenType[]);
}

export interface AuthenticateRequest {
/**
* @example
Expand All @@ -96,7 +113,7 @@ export interface AuthenticateRequest {
<T extends readonly TokenType[]>(
request: Request,
options: AuthenticateRequestOptions & { acceptsToken: T },
): Promise<RequestState<T[number]>>;
): Promise<RequestState<T[number] | null>>;

/**
* @example
Expand All @@ -123,7 +140,7 @@ export interface AuthenticateRequest {
export const authenticateRequest: AuthenticateRequest = (async (
request: Request,
options: AuthenticateRequestOptions,
): Promise<RequestState<TokenType>> => {
): Promise<RequestState<TokenType> | UnauthenticatedState<null>> => {
const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options);
assertValidSecretKey(authenticateContext.secretKey);

Expand Down Expand Up @@ -652,16 +669,6 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleSessionTokenError(new Error('Missing token in header'), 'header');
}

// Handle case where tokenType is any and the token is not a machine token
if (!isMachineTokenByPrefix(tokenInHeader)) {
return signedOut({
tokenType: acceptsToken as MachineTokenType,
authenticateContext,
reason: AuthErrorReason.TokenTypeMismatch,
message: '',
});
}

const parsedTokenType = getMachineTokenType(tokenInHeader);
const mismatchState = checkTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext);
if (mismatchState) {
Expand Down Expand Up @@ -722,15 +729,21 @@ export const authenticateRequest: AuthenticateRequest = (async (
});
}

// If acceptsToken is an array, early check if the token is in the accepted array
// to avoid unnecessary verification calls
if (Array.isArray(acceptsToken)) {
if (!isTokenTypeInAcceptedArray(acceptsToken, authenticateContext)) {
return signedOutInvalidToken();
}
}

if (authenticateContext.tokenInHeader) {
if (acceptsToken === 'any') {
return authenticateAnyRequestWithTokenInHeader();
}

if (acceptsToken === TokenType.SessionToken) {
return authenticateRequestWithTokenInHeader();
}

return authenticateMachineRequestWithTokenInHeader();
}

Expand Down
16 changes: 8 additions & 8 deletions packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,15 +477,15 @@ describe('clerkMiddleware(params)', () => {
const req = mockRequest({
url: '/api/protected',
headers: new Headers({
[constants.Headers.Authorization]: 'Bearer api_key_xxxxxxxxxxxxxxxxxx',
[constants.Headers.Authorization]: 'Bearer ak_123',
}),
});

authenticateRequestMock.mockResolvedValueOnce({
publishableKey,
status: AuthStatus.SignedIn,
headers: new Headers(),
toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'api_key_xxxxxxxxxxxxxxxxxx' }),
toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'ak_123', isAuthenticated: true }),
});

const resp = await clerkMiddleware(async auth => {
Expand Down Expand Up @@ -525,7 +525,7 @@ describe('clerkMiddleware(params)', () => {
const req = mockRequest({
url: '/protected',
headers: new Headers({
[constants.Headers.Authorization]: 'Bearer api_key_xxxxxxxxxxxxxxxxxx',
[constants.Headers.Authorization]: 'Bearer ak_123',
}),
});

Expand All @@ -552,7 +552,7 @@ describe('clerkMiddleware(params)', () => {
const req = mockRequest({
url: '/protected',
headers: new Headers({
[constants.Headers.Authorization]: 'Bearer oauth_token_xxxxxxxxxxxxxxxxxx',
[constants.Headers.Authorization]: 'Bearer oat_123',
}),
});

Expand Down Expand Up @@ -658,7 +658,7 @@ describe('clerkMiddleware(params)', () => {
const req = mockRequest({
url: '/api/protected',
headers: new Headers({
[constants.Headers.Authorization]: 'Bearer m2m_xxxxxxxxxxxxxxxxxx',
[constants.Headers.Authorization]: 'Bearer m2m_123',
}),
});

Expand All @@ -681,15 +681,15 @@ describe('clerkMiddleware(params)', () => {
const req = mockRequest({
url: '/api/protected',
headers: new Headers({
[constants.Headers.Authorization]: 'Bearer api_key_xxx',
[constants.Headers.Authorization]: 'Bearer ak_123',
}),
});

authenticateRequestMock.mockResolvedValueOnce({
publishableKey,
status: AuthStatus.SignedIn,
headers: new Headers(),
toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'api_key_xxxxxxxxxxxxxxxxxx' }),
toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'ak_123', isAuthenticated: true }),
});

const resp = await clerkMiddleware(async auth => {
Expand All @@ -705,7 +705,7 @@ describe('clerkMiddleware(params)', () => {
const req = mockRequest({
url: '/api/protected',
headers: new Headers({
[constants.Headers.Authorization]: 'Bearer api_key_xxx',
[constants.Headers.Authorization]: 'Bearer ak_123',
}),
});

Expand Down
Loading
Loading