Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
5fb7708
feat(backend): Add machine authentication support
wobsoriano Apr 22, 2025
f0cd31c
chore: re-export auth object types and functions
wobsoriano Apr 22, 2025
0f6784f
test: add machine token location tests
wobsoriano Apr 22, 2025
86f1e9b
chore: Update OAuth prefix
wobsoriano Apr 22, 2025
f2395f8
chore: add scopes to machine auth tokens
wobsoriano Apr 23, 2025
03d7dd3
chore: switch to interface overloads
wobsoriano Apr 24, 2025
141ab21
chore: separate any token type and invidual with examples
wobsoriano Apr 24, 2025
74e5aa9
chore: export verifyMachineAuthToken internally
wobsoriano Apr 24, 2025
28628fa
chore: rename NonSessionTokenType to something more direct
wobsoriano Apr 25, 2025
d74af11
chore: drop claims from oauth access token
wobsoriano Apr 25, 2025
1273b92
chore: Allow custom properties per machine token type
wobsoriano Apr 25, 2025
c33c276
chore: type clean up and export machine utilities
wobsoriano Apr 25, 2025
67ade6e
chore: clean up tests
wobsoriano Apr 25, 2025
16ad19e
chore: Add default version for machine auth urls
wobsoriano Apr 25, 2025
1bf3437
chore: clean up more tests
wobsoriano Apr 25, 2025
6d41463
chore: fix auth object types
wobsoriano Apr 28, 2025
2d97101
chore: adjust nuxt types
wobsoriano Apr 28, 2025
41fd7d0
chore: Update other SDKs to conform with updated AuthObject
wobsoriano Apr 28, 2025
8b13138
chore: fix nuxt types
wobsoriano Apr 28, 2025
1e2441c
chore: introduce TokenType const
wobsoriano Apr 28, 2025
c4cab10
chore: fix incorrect import
wobsoriano Apr 28, 2025
da5260d
chore: export other token types
wobsoriano Apr 29, 2025
f61d668
chore: add remark about token prefixes
wobsoriano Apr 29, 2025
651b01a
chore: update doc regarding versioning
wobsoriano Apr 29, 2025
576d7c4
chore: update doc regarding versioning
wobsoriano Apr 29, 2025
b8eb01f
chore: update to use new token prefixes
wobsoriano May 1, 2025
58c0c97
chore: address conflicts
wobsoriano May 1, 2025
e42fa3c
chore: move some types to types file
wobsoriano May 1, 2025
837fa37
chore: accommodate update properties and new prefixes
wobsoriano May 1, 2025
848d053
chore: type adjustments
wobsoriano May 1, 2025
01a67a0
chore: remove todo comments in nextjs
wobsoriano May 1, 2025
9c65ad0
chore: update remaining strings to use token type enums
wobsoriano May 2, 2025
ff52b64
chore: make parsed token in header property name simple
wobsoriano May 2, 2025
86580ab
chore: update remaining strings to use token type enums
wobsoriano May 2, 2025
4ca8a76
chore: rename session token error handling function
wobsoriano May 2, 2025
572854e
chore: rename mismatched token checker name to checkTokenTypeMismatch
wobsoriano May 2, 2025
25e1f50
chore: test default token type
wobsoriano May 5, 2025
5bf3c42
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 5, 2025
ec1ccb9
feat(nextjs): Introduce machine authentication (#5710)
wobsoriano May 6, 2025
7facc65
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 6, 2025
1a779c1
chore: add changesets
wobsoriano May 6, 2025
bc2eb11
chore: clean up tests
wobsoriano May 6, 2025
747123c
chore: run dedupe
wobsoriano May 6, 2025
178e823
chore: fix typedoc issues
wobsoriano May 6, 2025
28a04d7
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 7, 2025
1cc8d50
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 12, 2025
dbafb8e
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 14, 2025
58c0fd7
chore(nextjs): Restrict keyless to session auth (#5930)
wobsoriano May 15, 2025
4fc6389
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 15, 2025
a3db154
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 15, 2025
1b60ce9
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 17, 2025
36c7c70
chore: add description and last_used_at fields to api keys
wobsoriano May 19, 2025
2032103
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 21, 2025
61b4550
chore: type fixes
wobsoriano May 21, 2025
799e436
chore: type fixes
wobsoriano May 21, 2025
dfc49b1
chore: type fixes
wobsoriano May 21, 2025
d887725
chore: Clean up get auth options
wobsoriano May 21, 2025
d0d2f87
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 22, 2025
c51f2e1
Merge branch 'main' into rob/robo-36-sdk-m2m
wobsoriano May 30, 2025
ca83aff
chore: sort imports
wobsoriano May 30, 2025
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/bumpy-carpets-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@clerk/backend': major
---

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 `authenticateRequest()` 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 { clerkClient } from '@clerk/backend';

const app = express();

app.use(async (req, res, next) => {
const requestState = await clerkClient.authenticateRequest(req, {
acceptsToken: 'any'
});

if (!requestState.isAuthenticated) {
// do something for unauthenticated requests
}

const authObject = requestState.toAuth();

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

// Attach the auth object to locals so downstream handlers
// and middleware can access it
res.locals.auth = authObject;
next();
});
```
30 changes: 30 additions & 0 deletions .changeset/chatty-lions-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@clerk/tanstack-react-start': minor
'@clerk/agent-toolkit': minor
'@clerk/react-router': minor
'@clerk/express': minor
'@clerk/fastify': minor
'@clerk/astro': minor
'@clerk/remix': minor
'@clerk/nuxt': minor
---

Machine authentication is now supported for advanced use cases via the backend SDK. You can use `clerkClient.authenticateRequest` to validate machine tokens (such as API keys, OAuth tokens, and machine-to-machine tokens). No new helpers are included in these packages yet.

Example (Astro):

```ts
import { clerkClient } from '@clerk/astro/server';

export const GET: APIRoute = ({ request }) => {
const requestState = await clerkClient.authenticateRequest(request, {
acceptsToken: 'api_key'
});

if (!requestState.isAuthenticated) {
return new Response(401, { message: 'Unauthorized' })
}

return new Response(JSON.stringify(requestState.toAuth()))
}
```
57 changes: 57 additions & 0 deletions .changeset/fast-turkeys-melt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@clerk/nextjs': 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 for a given route or handler using the `acceptsToken` property in the `auth()` helper, or the `token` property in the `auth.protect()` helper. Each can be set to a specific type, an array of types, or `'any'` to accept all supported tokens.

Example usage in Nextjs middleware:

```ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isOAuthAccessible = createRouteMatcher(['/oauth(.*)'])
const isApiKeyAccessible = createRouteMatcher(['/api(.*)'])
const isMachineTokenAccessible = createRouteMatcher(['/m2m(.*)'])
const isUserAccessible = createRouteMatcher(['/user(.*)'])
const isAccessibleToAnyValidToken = createRouteMatcher(['/any(.*)'])

export default clerkMiddleware(async (auth, req) => {
if (isOAuthAccessible(req)) await auth.protect({ token: 'oauth_token' })
if (isApiKeyAccessible(req)) await auth.protect({ token: 'api_key' })
if (isMachineTokenAccessible(req)) await auth.protect({ token: 'machine_token' })
if (isUserAccessible(req)) await auth.protect({ token: 'session_token' })

if (isAccessibleToAnyValidToken(req)) await auth.protect({ token: 'any' })
});

export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
}
```

Leaf node route protection:

```ts
import { auth } from '@clerk/nextjs/server'

// In this example, we allow users and oauth tokens with the "profile" scope
// to access the data. Other types of tokens are rejected.
function POST(req, res) {
const authObject = await auth({ acceptsToken: ['session_token', 'oauth_token'] })

if (authObject.tokenType === 'oauth_token' &&
!authObject.scopes?.includes('profile')) {
throw new Error('Unauthorized: OAuth token missing the "profile" scope')
}

// get data from db using userId
const data = db.select().from(user).where(eq(user.id, authObject.userId))

return { data }
}
```
2 changes: 2 additions & 0 deletions .typedoc/__tests__/__snapshots__/file-structure.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
"shared/version-selector.mdx",
"nextjs/auth.mdx",
"nextjs/build-clerk-props.mdx",
"nextjs/clerk-middleware-auth-object.mdx",
"nextjs/clerk-middleware-options.mdx",
"nextjs/clerk-middleware.mdx",
"nextjs/create-async-get-auth.mdx",
Expand All @@ -139,6 +140,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
"clerk-react/use-sign-in.mdx",
"clerk-react/use-sign-up.mdx",
"clerk-react/use-user.mdx",
"backend/verify-machine-auth-token.mdx",
"backend/verify-token-options.mdx",
"backend/verify-token.mdx",
"backend/verify-webhook-options.mdx",
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-toolkit/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AuthObject, ClerkClient } from '@clerk/backend';
import type { ClerkClient } from '@clerk/backend';
import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal';

import type { ClerkTool } from './clerk-tool';

Expand All @@ -12,7 +13,7 @@ export type ToolkitParams = {
* @default {}
*/
authContext?: Pick<
AuthObject,
SignedInAuthObject | SignedOutAuthObject,
'userId' | 'sessionId' | 'sessionClaims' | 'orgId' | 'orgRole' | 'orgSlug' | 'orgPermissions' | 'actor'
>;
/**
Expand Down
13 changes: 10 additions & 3 deletions packages/astro/src/server/clerk-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { AuthObject, ClerkClient } from '@clerk/backend';
import type { AuthenticateRequestOptions, ClerkRequest, RedirectFun, RequestState } from '@clerk/backend/internal';
import type { ClerkClient } from '@clerk/backend';
import type {
AuthenticateRequestOptions,
ClerkRequest,
RedirectFun,
RequestState,
SignedInAuthObject,
SignedOutAuthObject,
} from '@clerk/backend/internal';
import { AuthStatus, constants, createClerkRequest, createRedirect } from '@clerk/backend/internal';
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
Expand Down Expand Up @@ -28,7 +35,7 @@ const CONTROL_FLOW_ERROR = {
REDIRECT_TO_SIGN_IN: 'CLERK_PROTECT_REDIRECT_TO_SIGN_IN',
};

type ClerkMiddlewareAuthObject = AuthObject & {
type ClerkMiddlewareAuthObject = (SignedInAuthObject | SignedOutAuthObject) & {
redirectToSignIn: (opts?: { returnBackUrl?: URL | string | null }) => Response;
};

Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/server/get-auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AuthObject } from '@clerk/backend';
import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal';
import { AuthStatus, signedInAuthObject, signedOutAuthObject } from '@clerk/backend/internal';
import { decodeJwt } from '@clerk/backend/jwt';
import type { PendingSessionOptions } from '@clerk/types';
Expand All @@ -7,7 +7,7 @@ import type { APIContext } from 'astro';
import { getSafeEnv } from './get-safe-env';
import { getAuthKeyFromRequest } from './utils';

export type GetAuthReturn = AuthObject;
export type GetAuthReturn = SignedInAuthObject | SignedOutAuthObject;

export const createGetAuth = ({ noAuthStatusMessage }: { noAuthStatusMessage: string }) => {
return (
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ describe('subpath /errors exports', () => {
it('should not include a breaking change', () => {
expect(Object.keys(errorExports).sort()).toMatchInlineSnapshot(`
[
"MachineTokenVerificationError",
"MachineTokenVerificationErrorCode",
"SignJWTError",
"TokenVerificationError",
"TokenVerificationErrorAction",
Expand All @@ -37,18 +39,25 @@ describe('subpath /internal exports', () => {
expect(Object.keys(internalExports).sort()).toMatchInlineSnapshot(`
[
"AuthStatus",
"TokenType",
"authenticatedMachineObject",
"constants",
"createAuthenticateRequest",
"createClerkRequest",
"createRedirect",
"debugRequestState",
"decorateObjectWithResources",
"getMachineTokenType",
"isMachineToken",
"isTokenTypeAccepted",
"makeAuthObjectSerializable",
"reverificationError",
"reverificationErrorResponse",
"signedInAuthObject",
"signedOutAuthObject",
"stripPrivateDataFromObject",
"unauthenticatedMachineObject",
"verifyMachineAuthToken",
]
`);
});
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/api/endpoints/APIKeysApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { joinPaths } from '../../util/path';
import type { APIKey } from '../resources/APIKey';
import { AbstractAPI } from './AbstractApi';

const basePath = '/api_keys';

export class APIKeysAPI extends AbstractAPI {
async verifySecret(secret: string) {
return this.request<APIKey>({
method: 'POST',
path: joinPaths(basePath, 'verify'),
bodyParams: { secret },
});
}
}
15 changes: 15 additions & 0 deletions packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { joinPaths } from '../../util/path';
import type { IdPOAuthAccessToken } from '../resources';
import { AbstractAPI } from './AbstractApi';

const basePath = '/oauth_applications/access_tokens';

export class IdPOAuthAccessTokenApi extends AbstractAPI {
async verifySecret(secret: string) {
return this.request<IdPOAuthAccessToken>({
method: 'POST',
path: joinPaths(basePath, 'verify'),
bodyParams: { secret },
});
}
}
15 changes: 15 additions & 0 deletions packages/backend/src/api/endpoints/MachineTokensApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { joinPaths } from '../../util/path';
import type { MachineToken } from '../resources/MachineToken';
import { AbstractAPI } from './AbstractApi';

const basePath = '/m2m_tokens';

export class MachineTokensApi extends AbstractAPI {
async verifySecret(secret: string) {
return this.request<MachineToken>({
method: 'POST',
path: joinPaths(basePath, 'verify'),
bodyParams: { secret },
});
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ export * from './ActorTokenApi';
export * from './AccountlessApplicationsAPI';
export * from './AbstractApi';
export * from './AllowlistIdentifierApi';
export * from './APIKeysApi';
export * from './BetaFeaturesApi';
export * from './BlocklistIdentifierApi';
export * from './ClientApi';
export * from './DomainApi';
export * from './EmailAddressApi';
export * from './IdPOAuthAccessTokenApi';
Copy link
Member

Choose a reason for hiding this comment

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

Why does this one start with IdP?

Copy link
Member Author

@wobsoriano wobsoriano May 2, 2025

Choose a reason for hiding this comment

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

So.. this was hard to name. We already have:

  1. OauthAccessToken which is used for retrieving the OAuth access token of a user
  2. OAuthApplication for OAuth applications

I named it like that based on the object name used (clerk_idp_oauth_access_token) for response deserialization but suggestions welcome!

export * from './InstanceApi';
export * from './InvitationApi';
export * from './MachineTokensApi';
export * from './JwksApi';
export * from './JwtTemplatesApi';
export * from './OrganizationApi';
Expand Down
24 changes: 24 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import {
AccountlessApplicationAPI,
ActorTokenAPI,
AllowlistIdentifierAPI,
APIKeysAPI,
BetaFeaturesAPI,
BlocklistIdentifierAPI,
ClientAPI,
DomainAPI,
EmailAddressAPI,
IdPOAuthAccessTokenApi,
InstanceAPI,
InvitationAPI,
JwksAPI,
JwtTemplatesApi,
MachineTokensApi,
OAuthApplicationsApi,
OrganizationAPI,
PhoneNumberAPI,
Expand Down Expand Up @@ -47,6 +50,27 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
emailAddresses: new EmailAddressAPI(request),
instance: new InstanceAPI(request),
invitations: new InvitationAPI(request),
// Using "/" instead of an actual version since they're bapi-proxy endpoints.
// bapi-proxy connects directly to C1 without URL versioning,
// while API versioning is handled through the Clerk-API-Version header.
Comment on lines +53 to +55
Copy link
Member

Choose a reason for hiding this comment

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

Huh, this is surprising to me, I would expect the path structure at edge to mirror origin 🤔 I'll start a thread in Slack

Copy link
Member Author

Choose a reason for hiding this comment

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

I saw the thread. Yeah, I also thought of creating a separate buildRequest function for bapi-proxy but that'd be too much considering the logic we already have when building a request. For now I'm passing "/" to bypass the api version during URL normalization

machineTokens: new MachineTokensApi(
buildRequest({
...options,
apiVersion: '/',
}),
),
idPOAuthAccessToken: new IdPOAuthAccessTokenApi(
buildRequest({
...options,
apiVersion: '/',
}),
),
apiKeys: new APIKeysAPI(
buildRequest({
...options,
apiVersion: '/',
}),
),
Comment on lines +56 to +73
Copy link
Member Author

Choose a reason for hiding this comment

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

These are just placeholder versions. When normalized, the "/" will be removed from the URL path while still maintaining version control through the Clerk-API-Version header. bapi-proxy does not have it in the URL

jwks: new JwksAPI(request),
jwtTemplates: new JwtTemplatesApi(request),
oauthApplications: new OAuthApplicationsApi(request),
Expand Down
41 changes: 41 additions & 0 deletions packages/backend/src/api/resources/APIKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { APIKeyJSON } from './JSON';

export class APIKey {
constructor(
readonly id: string,
readonly type: string,
readonly name: string,
readonly subject: string,
readonly scopes: string[],
readonly claims: Record<string, any> | null,
readonly revoked: boolean,
readonly revocationReason: string | null,
readonly expired: boolean,
readonly expiration: number | null,
readonly createdBy: string | null,
readonly description: string | null,
readonly lastUsedAt: number | null,
readonly createdAt: number,
readonly updatedAt: number,
) {}

static fromJSON(data: APIKeyJSON) {
return new APIKey(
data.id,
data.type,
data.name,
data.subject,
data.scopes,
data.claims,
data.revoked,
data.revocation_reason,
data.expired,
data.expiration,
data.created_by,
data.description,
data.last_used_at,
data.created_at,
data.updated_at,
);
}
}
Loading
Loading