- Notifications
You must be signed in to change notification settings - Fork 402
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
Changes from all commits
5fb7708 f0cd31c 0f6784f 86f1e9b f2395f8 03d7dd3 141ab21 74e5aa9 28628fa d74af11 1273b92 c33c276 67ade6e 16ad19e 1bf3437 6d41463 2d97101 41fd7d0 8b13138 1e2441c c4cab10 da5260d f61d668 651b01a 576d7c4 b8eb01f 58c0c97 e42fa3c 837fa37 848d053 01a67a0 9c65ad0 ff52b64 86580ab 4ca8a76 572854e 25e1f50 5bf3c42 ec1ccb9 7facc65 1a779c1 bc2eb11 747123c 178e823 28a04d7 1cc8d50 dbafb8e 58c0fd7 4fc6389 a3db154 1b60ce9 36c7c70 2032103 61b4550 799e436 dfc49b1 d887725 d0d2f87 c51f2e1 ca83aff File filter
Filter by extension
Conversations
Jump to
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 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 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 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, | ||
| ); | ||
| } | ||
| } |
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!