Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 470
feat(backend,nextjs): Introduce machine authentication#5689
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
5fb7708f0cd31c0f6784f86f1e9bf2395f803d7dd3141ab2174e5aa928628fad74af111273b92c33c27667ade6e16ad19e1bf34376d414632d9710141fd7d08b131381e2441cc4cab10da5260df61d668651b01a576d7c4b8eb01f58c0c97e42fa3c837fa37848d05301a67a09c65ad0ff52b6486580ab4ca8a76572854e25e1f505bf3c42ec1ccb97facc651a779c1bc2eb11747123c178e82328a04d71cc8d50dbafb8e58c0fd74fc6389a3db1541b60ce936c7c70203210361b4550799e436dfc49b1d887725d0d2f87c51f2e1ca83affFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
| ``` |
| 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())) | ||
| } | ||
| ``` |
| 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 } | ||
| } | ||
| ``` |
| 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 }, | ||
| }); | ||
| } | ||
| } |
| 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 }, | ||
| }); | ||
| } | ||
| } |
| 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 }, | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -2,15 +2,18 @@ import { | ||
| AccountlessApplicationAPI, | ||
| ActorTokenAPI, | ||
| AllowlistIdentifierAPI, | ||
| APIKeysAPI, | ||
| BetaFeaturesAPI, | ||
| BlocklistIdentifierAPI, | ||
| ClientAPI, | ||
| DomainAPI, | ||
| EmailAddressAPI, | ||
| IdPOAuthAccessTokenApi, | ||
| InstanceAPI, | ||
| InvitationAPI, | ||
| JwksAPI, | ||
| JwtTemplatesApi, | ||
| MachineTokensApi, | ||
| OAuthApplicationsApi, | ||
| OrganizationAPI, | ||
| PhoneNumberAPI, | ||
| @@ -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
Member There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 MemberAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I saw the thread. Yeah, I also thought of creating a separate | ||
| machineTokens: new MachineTokensApi( | ||
| buildRequest({ | ||
| ...options, | ||
| apiVersion: '/', | ||
| }), | ||
| ), | ||
| idPOAuthAccessToken: new IdPOAuthAccessTokenApi( | ||
| buildRequest({ | ||
| ...options, | ||
| apiVersion: '/', | ||
| }), | ||
| ), | ||
| apiKeys: new APIKeysAPI( | ||
| buildRequest({ | ||
| ...options, | ||
| apiVersion: '/', | ||
| }), | ||
| ), | ||
Comment on lines
+56
to
+73
MemberAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| jwks: new JwksAPI(request), | ||
| jwtTemplates: new JwtTemplatesApi(request), | ||
| oauthApplications: new OAuthApplicationsApi(request), | ||
| 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, | ||
| ); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
I named it like that based on the object name used (clerk_idp_oauth_access_token) for response deserialization but suggestions welcome!