Skip to content

feat(express,fastify,tanstack-react-start,react-router): Support machine auth tokens in getAuth() #6067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from all 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/free-times-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/backend': patch
'@clerk/nextjs': patch
---

Re-organize internal types for the recently added "machine authentication" feature.
30 changes: 30 additions & 0 deletions .changeset/large-adults-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@clerk/tanstack-react-start': minor
---

Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification.

You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens.

Example usage:

```ts
import { createServerFn } from '@tanstack/react-start'
import { getAuth } from '@clerk/tanstack-react-start/server'
import { getWebRequest } from '@tanstack/react-start/server'

const authStateFn = createServerFn({ method: 'GET' }).handler(async () => {
const request = getWebRequest()
const auth = await getAuth(request, { acceptsToken: 'any' })

if (authObject.tokenType === 'session_token') {
console.log('this is session token from a user')
} else {
console.log('this is some other type of machine token')
console.log('more specifically, a ' + authObject.tokenType)
}

return {}
})

```
27 changes: 27 additions & 0 deletions .changeset/sour-onions-wear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@clerk/express': minor
---

Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification.

You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens.

Example usage:

```ts
import express from 'express';
import { getAuth } from '@clerk/express';

const app = express();

app.get('/path', (req, res) => {
const authObject = getAuth(req, { acceptsToken: 'any' });

if (authObject.tokenType === 'session_token') {
console.log('this is session token from a user')
} else {
console.log('this is some other type of machine token')
console.log('more specifically, a ' + authObject.tokenType)
}
});
```
27 changes: 27 additions & 0 deletions .changeset/two-trains-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@clerk/react-router': minor
---

Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification.

You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens.

Example usage:

```ts
import { getAuth } from '@clerk/react-router/ssr.server'
import type { Route } from './+types/profile'

export async function loader(args: Route.LoaderArgs) {
const authObject = await getAuth(args, { acceptsToken: 'any' })

if (authObject.tokenType === 'session_token') {
console.log('this is session token from a user')
} else {
console.log('this is some other type of machine token')
console.log('more specifically, a ' + authObject.tokenType)
}

return {}
}
```
27 changes: 27 additions & 0 deletions .changeset/yummy-socks-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@clerk/fastify': minor
---

Introduces machine authentication, supporting four token types: `api_key`, `oauth_token`, `machine_token`, and `session_token`. For backwards compatibility, `session_token` remains the default when no token type is specified. This enables machine-to-machine authentication and use cases such as API keys and OAuth integrations. Existing applications continue to work without modification.

You can specify which token types are allowed by using the `acceptsToken` option in the `getAuth()` function. This option can be set to a specific type, an array of types, or `'any'` to accept all supported tokens.

Example usage:

```ts
import Fastify from 'fastify'
import { getAuth } from '@clerk/fastify'

const fastify = Fastify()

fastify.get('/path', (request, reply) => {
const authObject = getAuth(req, { acceptsToken: 'any' });

if (authObject.tokenType === 'session_token') {
console.log('this is session token from a user')
} else {
console.log('this is some other type of machine token')
console.log('more specifically, a ' + authObject.tokenType)
}
});
```
3 changes: 3 additions & 0 deletions .typedoc/__tests__/__snapshots__/file-structure.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
"backend/client.mdx",
"backend/email-address.mdx",
"backend/external-account.mdx",
"backend/get-auth-fn.mdx",
"backend/identification-link.mdx",
"backend/infer-auth-object-from-token-array.mdx",
"backend/infer-auth-object-from-token.mdx",
"backend/invitation-status.mdx",
"backend/invitation.mdx",
"backend/organization-invitation-status.mdx",
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('subpath /internal exports', () => {
"createRedirect",
"debugRequestState",
"decorateObjectWithResources",
"getAuthObjectForAcceptedToken",
"getAuthObjectFromJwt",
"getMachineTokenType",
"isMachineToken",
Expand Down
11 changes: 10 additions & 1 deletion packages/backend/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ export { createAuthenticateRequest } from './tokens/factory';

export { debugRequestState } from './tokens/request';

export type { AuthenticateRequestOptions, OrganizationSyncOptions } from './tokens/types';
export type {
AuthenticateRequestOptions,
OrganizationSyncOptions,
InferAuthObjectFromToken,
InferAuthObjectFromTokenArray,
SessionAuthObject,
MachineAuthObject,
GetAuthFn,
} from './tokens/types';

export { TokenType } from './tokens/tokenTypes';
export type { SessionTokenType, MachineTokenType } from './tokens/tokenTypes';
Expand All @@ -26,6 +34,7 @@ export {
authenticatedMachineObject,
unauthenticatedMachineObject,
getAuthObjectFromJwt,
getAuthObjectForAcceptedToken,
} from './tokens/authObjects';

export { AuthStatus } from './tokens/authStatus';
Expand Down
63 changes: 62 additions & 1 deletion packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import type {

import type { APIKey, CreateBackendApiOptions, MachineToken } from '../api';
import { createBackendApiClient } from '../api';
import { isTokenTypeAccepted } from '../internal';
import type { AuthenticateContext } from './authenticateContext';
import type { MachineTokenType, SessionTokenType } from './tokenTypes';
import { TokenType } from './tokenTypes';
import type { MachineAuthType } from './types';
import type { AuthenticateRequestOptions, MachineAuthType } from './types';

/**
* @inline
Expand Down Expand Up @@ -361,3 +362,63 @@ export const getAuthObjectFromJwt = (

return authObject;
};

/**
* @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.
*
* @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: 'mt_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
*/
export function getAuthObjectForAcceptedToken({
authObject,
acceptsToken = TokenType.SessionToken,
}: {
authObject: AuthObject;
acceptsToken: AuthenticateRequestOptions['acceptsToken'];
}): AuthObject {
if (acceptsToken === 'any') {
return authObject;
}

if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) {
if (authObject.tokenType === TokenType.SessionToken) {
return signedOutAuthObject(authObject.debug);
}
return unauthenticatedMachineObject(authObject.tokenType, authObject.debug);
}

return authObject;
}
83 changes: 82 additions & 1 deletion packages/backend/src/tokens/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import type { MatchFunction } from '@clerk/shared/pathToRegexp';
import type { PendingSessionOptions } from '@clerk/types';

import type { ApiClient, APIKey, IdPOAuthAccessToken, MachineToken } from '../api';
import type { TokenType } from './tokenTypes';
import type {
AuthenticatedMachineObject,
AuthObject,
SignedInAuthObject,
SignedOutAuthObject,
UnauthenticatedMachineObject,
} from './authObjects';
import type { SessionTokenType, TokenType } from './tokenTypes';
import type { VerifyTokenOptions } from './verify';

/**
Expand Down Expand Up @@ -141,3 +149,76 @@ export type OrganizationSyncTargetMatchers = {
export type OrganizationSyncTarget =
| { type: 'personalAccount' }
| { type: 'organization'; organizationId?: string; organizationSlug?: string };

/**
* 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 };

export type SessionAuthObject = SignedInAuthObject | SignedOutAuthObject;
export type MachineAuthObject<T extends TokenType> = (AuthenticatedMachineObject | UnauthenticatedMachineObject) & {
tokenType: T;
};

type AuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] };

type MaybePromise<T, IsPromise extends boolean> = IsPromise extends true ? Promise<T> : T;

/**
* Shared generic overload type for getAuth() helpers across SDKs.
*
* - Parameterized by the request type (RequestType).
* - Handles different accepted token types and their corresponding return types.
*/
export interface GetAuthFn<RequestType, ReturnsPromise extends boolean = false> {
/**
* @example
* const auth = await getAuth(req, { acceptsToken: ['session_token', 'api_key'] })
*/
<T extends TokenType[]>(
req: RequestType,
options: AuthOptions & { acceptsToken: T },
): MaybePromise<InferAuthObjectFromTokenArray<T, SessionAuthObject, MachineAuthObject<T[number]>>, ReturnsPromise>;

/**
* @example
* const auth = await getAuth(req, { acceptsToken: 'session_token' })
*/
<T extends TokenType>(
req: RequestType,
options: AuthOptions & { acceptsToken: T },
): MaybePromise<InferAuthObjectFromToken<T, SessionAuthObject, MachineAuthObject<T>>, ReturnsPromise>;

/**
* @example
* const auth = await getAuth(req, { acceptsToken: 'any' })
*/
(req: RequestType, options: AuthOptions & { acceptsToken: 'any' }): MaybePromise<AuthObject, ReturnsPromise>;

/**
* @example
* const auth = await getAuth(req)
*/
(req: RequestType, options?: PendingSessionOptions): MaybePromise<SessionAuthObject, ReturnsPromise>;
}
42 changes: 36 additions & 6 deletions packages/express/src/__tests__/getAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,46 @@ import { getAuth } from '../getAuth';
import { mockRequest, mockRequestWithAuth } from './helpers';

describe('getAuth', () => {
it('throws error if clerkMiddleware is not executed before getAuth', async () => {
it('throws error if clerkMiddleware is not executed before getAuth', () => {
expect(() => getAuth(mockRequest())).toThrow(/The "clerkMiddleware" should be registered before using "getAuth"/);
});

it('returns auth from request for signed-out request', async () => {
expect(getAuth(mockRequestWithAuth())).toHaveProperty('userId', null);
it('returns auth from request for signed-out request', () => {
const req = mockRequestWithAuth({ userId: null, tokenType: 'session_token' });
const auth = getAuth(req);
expect(auth.userId).toBeNull();
expect(auth.tokenType).toBe('session_token');
});

it('returns auth from request', async () => {
const req = mockRequestWithAuth({ userId: 'user_12345' });
expect(getAuth(req)).toHaveProperty('userId', 'user_12345');
it('returns auth from request', () => {
const req = mockRequestWithAuth({ userId: 'user_12345', tokenType: 'session_token' });
const auth = getAuth(req);
expect(auth.userId).toBe('user_12345');
expect(auth.tokenType).toBe('session_token');
});

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we wanna add a test case that asserts that the default acceptsToken is 'any'?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the actual authenticateRequest() call is using 'any' and cannot be overridden. Meaning any session or machine request will be verified.

However, the acceptsToken in getAuth() (which defaults to session_token) is used after authentication to filter or coerce the returned auth object to match the expected type.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry, I misread lol. The changeset is saying what you're saying. So never mind :)

it('returns the actual auth object when its tokenType matches acceptsToken', () => {
const req = mockRequestWithAuth({ tokenType: 'api_key', id: 'ak_1234', subject: 'api_key_1234' });
const result = getAuth(req, { acceptsToken: 'api_key' });
expect(result.tokenType).toBe('api_key');
expect(result.id).toBe('ak_1234');
expect(result.subject).toBe('api_key_1234');
});

it('returns the actual auth object if its tokenType is included in the acceptsToken array', () => {
const req = mockRequestWithAuth({ tokenType: 'machine_token', id: 'mt_1234' });
const result = getAuth(req, { acceptsToken: ['machine_token', 'api_key'] });
expect(result.tokenType).toBe('machine_token');
expect(result.id).toBe('mt_1234');
expect(result.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
// Properties specific to authenticated objects should be null or undefined
// @ts-expect-error - userId is not a property of the unauthenticated object
expect(result.userId).toBeNull();
});
});
Loading