diff --git a/.changeset/bumpy-carpets-study.md b/.changeset/bumpy-carpets-study.md new file mode 100644 index 00000000000..7be95325bac --- /dev/null +++ b/.changeset/bumpy-carpets-study.md @@ -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(); +}); +``` \ No newline at end of file diff --git a/.changeset/chatty-lions-stay.md b/.changeset/chatty-lions-stay.md new file mode 100644 index 00000000000..ad43d885caf --- /dev/null +++ b/.changeset/chatty-lions-stay.md @@ -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())) +} +``` \ No newline at end of file diff --git a/.changeset/fast-turkeys-melt.md b/.changeset/fast-turkeys-melt.md new file mode 100644 index 00000000000..84489ef0c94 --- /dev/null +++ b/.changeset/fast-turkeys-melt.md @@ -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 } +} +``` \ No newline at end of file diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index e470cf139e2..ff68a3e6cdb 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -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", @@ -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", diff --git a/packages/agent-toolkit/src/lib/types.ts b/packages/agent-toolkit/src/lib/types.ts index 9cccc3f033a..d505c849771 100644 --- a/packages/agent-toolkit/src/lib/types.ts +++ b/packages/agent-toolkit/src/lib/types.ts @@ -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'; @@ -12,7 +13,7 @@ export type ToolkitParams = { * @default {} */ authContext?: Pick< - AuthObject, + SignedInAuthObject | SignedOutAuthObject, 'userId' | 'sessionId' | 'sessionClaims' | 'orgId' | 'orgRole' | 'orgSlug' | 'orgPermissions' | 'actor' >; /** diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts index 9aa91a5ee5d..4953ac538e0 100644 --- a/packages/astro/src/server/clerk-middleware.ts +++ b/packages/astro/src/server/clerk-middleware.ts @@ -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'; @@ -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; }; diff --git a/packages/astro/src/server/get-auth.ts b/packages/astro/src/server/get-auth.ts index 0f6e45162ee..429cf27cb52 100644 --- a/packages/astro/src/server/get-auth.ts +++ b/packages/astro/src/server/get-auth.ts @@ -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'; @@ -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 ( diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 17c48942856..63b94c5bdae 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -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", @@ -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", ] `); }); diff --git a/packages/backend/src/api/endpoints/APIKeysApi.ts b/packages/backend/src/api/endpoints/APIKeysApi.ts new file mode 100644 index 00000000000..4cf973de28e --- /dev/null +++ b/packages/backend/src/api/endpoints/APIKeysApi.ts @@ -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({ + method: 'POST', + path: joinPaths(basePath, 'verify'), + bodyParams: { secret }, + }); + } +} diff --git a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts new file mode 100644 index 00000000000..589c81d6574 --- /dev/null +++ b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts @@ -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({ + method: 'POST', + path: joinPaths(basePath, 'verify'), + bodyParams: { secret }, + }); + } +} diff --git a/packages/backend/src/api/endpoints/MachineTokensApi.ts b/packages/backend/src/api/endpoints/MachineTokensApi.ts new file mode 100644 index 00000000000..4c61f35d235 --- /dev/null +++ b/packages/backend/src/api/endpoints/MachineTokensApi.ts @@ -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({ + method: 'POST', + path: joinPaths(basePath, 'verify'), + bodyParams: { secret }, + }); + } +} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index 2cf61f0af75..26b3f2e8d3f 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -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'; export * from './InstanceApi'; export * from './InvitationApi'; +export * from './MachineTokensApi'; export * from './JwksApi'; export * from './JwtTemplatesApi'; export * from './OrganizationApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 164cfb87d04..876d57486f6 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -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. + machineTokens: new MachineTokensApi( + buildRequest({ + ...options, + apiVersion: '/', + }), + ), + idPOAuthAccessToken: new IdPOAuthAccessTokenApi( + buildRequest({ + ...options, + apiVersion: '/', + }), + ), + apiKeys: new APIKeysAPI( + buildRequest({ + ...options, + apiVersion: '/', + }), + ), jwks: new JwksAPI(request), jwtTemplates: new JwtTemplatesApi(request), oauthApplications: new OAuthApplicationsApi(request), diff --git a/packages/backend/src/api/resources/APIKey.ts b/packages/backend/src/api/resources/APIKey.ts new file mode 100644 index 00000000000..513e8f5880c --- /dev/null +++ b/packages/backend/src/api/resources/APIKey.ts @@ -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 | 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, + ); + } +} diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index bb96d595f58..7037a4240ee 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -1,6 +1,7 @@ import { ActorToken, AllowlistIdentifier, + APIKey, BlocklistIdentifier, Client, Cookies, @@ -8,11 +9,13 @@ import { Domain, Email, EmailAddress, + IdPOAuthAccessToken, Instance, InstanceRestrictions, InstanceSettings, Invitation, JwtTemplate, + MachineToken, OauthAccessToken, OAuthApplication, Organization, @@ -86,6 +89,8 @@ function jsonToObject(item: any): any { return ActorToken.fromJSON(item); case ObjectType.AllowlistIdentifier: return AllowlistIdentifier.fromJSON(item); + case ObjectType.ApiKey: + return APIKey.fromJSON(item); case ObjectType.BlocklistIdentifier: return BlocklistIdentifier.fromJSON(item); case ObjectType.Client: @@ -98,6 +103,8 @@ function jsonToObject(item: any): any { return EmailAddress.fromJSON(item); case ObjectType.Email: return Email.fromJSON(item); + case ObjectType.IdpOAuthAccessToken: + return IdPOAuthAccessToken.fromJSON(item); case ObjectType.Instance: return Instance.fromJSON(item); case ObjectType.InstanceRestrictions: @@ -108,6 +115,8 @@ function jsonToObject(item: any): any { return Invitation.fromJSON(item); case ObjectType.JwtTemplate: return JwtTemplate.fromJSON(item); + case ObjectType.MachineToken: + return MachineToken.fromJSON(item); case ObjectType.OauthAccessToken: return OauthAccessToken.fromJSON(item); case ObjectType.OAuthApplication: diff --git a/packages/backend/src/api/resources/IdPOAuthAccessToken.ts b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts new file mode 100644 index 00000000000..7c1b3d5193e --- /dev/null +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -0,0 +1,33 @@ +import type { IdPOAuthAccessTokenJSON } from './JSON'; + +export class IdPOAuthAccessToken { + constructor( + readonly id: string, + readonly clientId: string, + readonly type: string, + readonly subject: string, + readonly scopes: string[], + readonly revoked: boolean, + readonly revocationReason: string | null, + readonly expired: boolean, + readonly expiration: number | null, + readonly createdAt: number, + readonly updatedAt: number, + ) {} + + static fromJSON(data: IdPOAuthAccessTokenJSON) { + return new IdPOAuthAccessToken( + data.id, + data.client_id, + data.type, + data.subject, + data.scopes, + data.revoked, + data.revocation_reason, + data.expired, + data.expiration, + data.created_at, + data.updated_at, + ); + } +} diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 4d016551f17..1cae9b84183 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -20,6 +20,7 @@ export const ObjectType = { AccountlessApplication: 'accountless_application', ActorToken: 'actor_token', AllowlistIdentifier: 'allowlist_identifier', + ApiKey: 'api_key', BlocklistIdentifier: 'blocklist_identifier', Client: 'client', Cookies: 'cookies', @@ -33,8 +34,10 @@ export const ObjectType = { InstanceRestrictions: 'instance_restrictions', InstanceSettings: 'instance_settings', Invitation: 'invitation', + MachineToken: 'machine_to_machine_token', JwtTemplate: 'jwt_template', OauthAccessToken: 'oauth_access_token', + IdpOAuthAccessToken: 'clerk_idp_oauth_access_token', OAuthApplication: 'oauth_application', Organization: 'organization', OrganizationDomain: 'organization_domain', @@ -674,6 +677,54 @@ export interface SamlAccountConnectionJSON extends ClerkResourceJSON { updated_at: number; } +export interface MachineTokenJSON extends ClerkResourceJSON { + object: typeof ObjectType.MachineToken; + name: string; + subject: string; + scopes: string[]; + claims: Record | null; + revoked: boolean; + revocation_reason: string | null; + expired: boolean; + expiration: number | null; + created_by: string | null; + creation_reason: string | null; + created_at: number; + updated_at: number; +} + +export interface APIKeyJSON extends ClerkResourceJSON { + object: typeof ObjectType.ApiKey; + type: string; + name: string; + subject: string; + scopes: string[]; + claims: Record | null; + revoked: boolean; + revocation_reason: string | null; + expired: boolean; + expiration: number | null; + created_by: string | null; + description: string | null; + last_used_at: number | null; + created_at: number; + updated_at: number; +} + +export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { + object: typeof ObjectType.IdpOAuthAccessToken; + client_id: string; + type: string; + subject: string; + scopes: string[]; + revoked: boolean; + revocation_reason: string | null; + expired: boolean; + expiration: number | null; + created_at: number; + updated_at: number; +} + export interface WebhooksSvixJSON { svix_url: string; } diff --git a/packages/backend/src/api/resources/MachineToken.ts b/packages/backend/src/api/resources/MachineToken.ts new file mode 100644 index 00000000000..1d19837bcdf --- /dev/null +++ b/packages/backend/src/api/resources/MachineToken.ts @@ -0,0 +1,37 @@ +import type { MachineTokenJSON } from './JSON'; + +export class MachineToken { + constructor( + readonly id: string, + readonly name: string, + readonly subject: string, + readonly scopes: string[], + readonly claims: Record | null, + readonly revoked: boolean, + readonly revocationReason: string | null, + readonly expired: boolean, + readonly expiration: number | null, + readonly createdBy: string | null, + readonly creationReason: string | null, + readonly createdAt: number, + readonly updatedAt: number, + ) {} + + static fromJSON(data: MachineTokenJSON) { + return new MachineToken( + data.id, + data.name, + data.subject, + data.scopes, + data.claims, + data.revoked, + data.revocation_reason, + data.expired, + data.expiration, + data.created_by, + data.creation_reason, + data.created_at, + data.updated_at, + ); + } +} diff --git a/packages/backend/src/api/resources/index.ts b/packages/backend/src/api/resources/index.ts index e21aa4ade25..585aa1e1bcc 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -1,6 +1,7 @@ export * from './ActorToken'; export * from './AccountlessApplication'; export * from './AllowlistIdentifier'; +export * from './APIKey'; export * from './BlocklistIdentifier'; export * from './Client'; export * from './CnameTarget'; @@ -23,11 +24,13 @@ export type { SignUpStatus } from '@clerk/types'; export * from './ExternalAccount'; export * from './IdentificationLink'; +export * from './IdPOAuthAccessToken'; export * from './Instance'; export * from './InstanceRestrictions'; export * from './InstanceSettings'; export * from './Invitation'; export * from './JSON'; +export * from './MachineToken'; export * from './JwtTemplate'; export * from './OauthAccessToken'; export * from './OAuthApplication'; diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 90cfdc343a3..2e4d6890d75 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -69,3 +69,30 @@ export class TokenVerificationError extends Error { } export class SignJWTError extends Error {} + +export const MachineTokenVerificationErrorCode = { + TokenInvalid: 'token-invalid', + InvalidSecretKey: 'secret-key-invalid', + UnexpectedError: 'unexpected-error', +} as const; + +export type MachineTokenVerificationErrorCode = + (typeof MachineTokenVerificationErrorCode)[keyof typeof MachineTokenVerificationErrorCode]; + +export class MachineTokenVerificationError extends Error { + code: MachineTokenVerificationErrorCode; + long_message?: string; + status: number; + + constructor({ message, code, status }: { message: string; code: MachineTokenVerificationErrorCode; status: number }) { + super(message); + Object.setPrototypeOf(this, MachineTokenVerificationError.prototype); + + this.code = code; + this.status = status; + } + + public getFullMessage() { + return `${this.message} (code=${this.code}, status=${this.status})`; + } +} diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts new file mode 100644 index 00000000000..32cbe56e7d0 --- /dev/null +++ b/packages/backend/src/fixtures/machine.ts @@ -0,0 +1,69 @@ +export const mockTokens = { + api_key: 'ak_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9=', + oauth_token: 'oat_8XOIucKvqHVr5tYP123456789abcdefghij', + machine_token: 'mt_8XOIucKvqHVr5tYP123456789abcdefghij', +} as const; + +export const mockVerificationResults = { + api_key: { + id: 'ak_ey966f1b1xf93586b2debdcadb0b3bd1', + type: 'api_key', + name: 'my-api-key', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + scopes: ['read:foo', 'write:bar'], + revoked: false, + revocationReason: null, + expired: false, + expiration: null, + createdBy: null, + creationReason: null, + secondsUntilExpiration: null, + createdAt: 1745354860746, + updatedAt: 1745354860746, + }, + oauth_token: { + id: 'oat_2VTWUzvGC5UhdJCNx6xG1D98edc', + clientId: 'client_2VTWUzvGC5UhdJCNx6xG1D98edc', + type: 'oauth:access_token', + name: 'GitHub OAuth', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + scopes: ['read:foo', 'write:bar'], + revoked: false, + revocationReason: null, + expired: false, + expiration: null, + createdAt: 1744928754551, + updatedAt: 1744928754551, + }, + machine_token: { + id: 'mt_ey966f1b1xf93586b2debdcadb0b3bd1', + name: 'my-machine-token', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + scopes: ['read:foo', 'write:bar'], + claims: { foo: 'bar' }, + revoked: false, + revocationReason: null, + expired: false, + expiration: null, + createdBy: null, + creationReason: null, + createdAt: 1745185445567, + updatedAt: 1745185445567, + }, +}; + +export const mockMachineAuthResponses = { + api_key: { + endpoint: 'https://api.clerk.test/api_keys/verify', + errorMessage: 'API key not found', + }, + oauth_token: { + endpoint: 'https://api.clerk.test/oauth_applications/access_tokens/verify', + errorMessage: 'OAuth token not found', + }, + machine_token: { + endpoint: 'https://api.clerk.test/m2m_tokens/verify', + errorMessage: 'Machine token not found', + }, +} as const; diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index c9b7daf9ffc..e782b4e3a9a 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -9,11 +9,32 @@ export { debugRequestState } from './tokens/request'; export type { AuthenticateRequestOptions, OrganizationSyncOptions } from './tokens/types'; -export type { SignedInAuthObjectOptions, SignedInAuthObject, SignedOutAuthObject } from './tokens/authObjects'; -export { makeAuthObjectSerializable, signedOutAuthObject, signedInAuthObject } from './tokens/authObjects'; +export { TokenType } from './tokens/tokenTypes'; +export type { SessionTokenType, MachineTokenType } from './tokens/tokenTypes'; + +export type { + SignedInAuthObjectOptions, + SignedInAuthObject, + SignedOutAuthObject, + AuthenticatedMachineObject, + UnauthenticatedMachineObject, +} from './tokens/authObjects'; +export { + makeAuthObjectSerializable, + signedOutAuthObject, + signedInAuthObject, + authenticatedMachineObject, + unauthenticatedMachineObject, +} from './tokens/authObjects'; export { AuthStatus } from './tokens/authStatus'; -export type { RequestState, SignedInState, SignedOutState } from './tokens/authStatus'; +export type { + RequestState, + SignedInState, + SignedOutState, + AuthenticatedState, + UnauthenticatedState, +} from './tokens/authStatus'; export { decorateObjectWithResources, stripPrivateDataFromObject } from './util/decorateObjectWithResources'; @@ -21,3 +42,7 @@ export { createClerkRequest } from './tokens/clerkRequest'; export type { ClerkRequest } from './tokens/clerkRequest'; export { reverificationError, reverificationErrorResponse } from '@clerk/shared/authorization-errors'; + +export { verifyMachineAuthToken } from './tokens/verify'; + +export { isMachineToken, getMachineTokenType, isTokenTypeAccepted } from './tokens/machine'; diff --git a/packages/backend/src/jwt/types.ts b/packages/backend/src/jwt/types.ts index 697df4abfcf..76ba1f9a360 100644 --- a/packages/backend/src/jwt/types.ts +++ b/packages/backend/src/jwt/types.ts @@ -1,3 +1,5 @@ +import type { MachineTokenType } from '../tokens/tokenTypes'; + export type JwtReturnType = | { data: R; @@ -7,3 +9,15 @@ export type JwtReturnType = data?: undefined; errors: [E]; }; + +export type MachineTokenReturnType = + | { + data: R; + tokenType: MachineTokenType; + errors?: undefined; + } + | { + data?: undefined; + tokenType: MachineTokenType; + errors: [E]; + }; diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index f79f9b17ddb..60bae6019d3 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -1,8 +1,15 @@ import type { JwtPayload } from '@clerk/types'; import { describe, expect, it } from 'vitest'; +import { mockTokens, mockVerificationResults } from '../../fixtures/machine'; import type { AuthenticateContext } from '../authenticateContext'; -import { makeAuthObjectSerializable, signedInAuthObject, signedOutAuthObject } from '../authObjects'; +import { + authenticatedMachineObject, + makeAuthObjectSerializable, + signedInAuthObject, + signedOutAuthObject, + unauthenticatedMachineObject, +} from '../authObjects'; describe('makeAuthObjectSerializable', () => { it('removes non-serializable props', () => { @@ -254,3 +261,104 @@ describe('signedInAuthObject', () => { }); }); }); + +describe('authenticatedMachineObject', () => { + const debugData = { foo: 'bar' }; + + describe('API Key authentication', () => { + const token = mockTokens.api_key; + const verificationResult = mockVerificationResults.api_key; + + it('getToken returns the token passed in', async () => { + const authObject = authenticatedMachineObject('api_key', token, verificationResult, debugData); + const retrievedToken = await authObject.getToken(); + expect(retrievedToken).toBe(token); + }); + + it('has() always returns false', () => { + const authObject = authenticatedMachineObject('api_key', token, verificationResult, debugData); + expect(authObject.has({})).toBe(false); + }); + + it('properly initializes properties', () => { + const authObject = authenticatedMachineObject('api_key', token, verificationResult, debugData); + expect(authObject.tokenType).toBe('api_key'); + expect(authObject.name).toBe('my-api-key'); + expect(authObject.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(authObject.scopes).toEqual(['read:foo', 'write:bar']); + expect(authObject.claims).toEqual({ foo: 'bar' }); + }); + }); + + describe('OAuth Access Token authentication', () => { + const token = mockTokens.oauth_token; + const verificationResult = mockVerificationResults.oauth_token; + + it('getToken returns the token passed in', async () => { + const authObject = authenticatedMachineObject('oauth_token', token, verificationResult, debugData); + const retrievedToken = await authObject.getToken(); + expect(retrievedToken).toBe(token); + }); + + it('has() always returns false', () => { + const authObject = authenticatedMachineObject('oauth_token', token, verificationResult, debugData); + expect(authObject.has({})).toBe(false); + }); + + it('properly initializes properties', () => { + const authObject = authenticatedMachineObject('oauth_token', token, verificationResult, debugData); + expect(authObject.tokenType).toBe('oauth_token'); + expect(authObject.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(authObject.scopes).toEqual(['read:foo', 'write:bar']); + }); + }); + + describe('Machine Token authentication', () => { + const debugData = { foo: 'bar' }; + const token = mockTokens.machine_token; + const verificationResult = mockVerificationResults.machine_token; + + it('getToken returns the token passed in', async () => { + const authObject = authenticatedMachineObject('machine_token', token, verificationResult, debugData); + const retrievedToken = await authObject.getToken(); + expect(retrievedToken).toBe(token); + }); + + it('has() always returns false', () => { + const authObject = authenticatedMachineObject('machine_token', token, verificationResult, debugData); + expect(authObject.has({})).toBe(false); + }); + + it('properly initializes properties', () => { + const authObject = authenticatedMachineObject('machine_token', token, verificationResult, debugData); + expect(authObject.tokenType).toBe('machine_token'); + expect(authObject.name).toBe('my-machine-token'); + expect(authObject.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(authObject.scopes).toEqual(['read:foo', 'write:bar']); + expect(authObject.claims).toEqual({ foo: 'bar' }); + }); + }); +}); + +describe('unauthenticatedMachineObject', () => { + it('properly initializes properties', () => { + const authObject = unauthenticatedMachineObject('machine_token'); + expect(authObject.tokenType).toBe('machine_token'); + expect(authObject.id).toBeNull(); + expect(authObject.name).toBeNull(); + expect(authObject.subject).toBeNull(); + expect(authObject.scopes).toBeNull(); + expect(authObject.claims).toBeNull(); + }); + + it('has() always returns false', () => { + const authObject = unauthenticatedMachineObject('machine_token'); + expect(authObject.has({})).toBe(false); + }); + + it('getToken always returns null ', async () => { + const authObject = unauthenticatedMachineObject('machine_token'); + const retrievedToken = await authObject.getToken(); + expect(retrievedToken).toBeNull(); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/authStatus.test.ts b/packages/backend/src/tokens/__tests__/authStatus.test.ts index 87b50c567b1..8f6dc1f9f2f 100644 --- a/packages/backend/src/tokens/__tests__/authStatus.test.ts +++ b/packages/backend/src/tokens/__tests__/authStatus.test.ts @@ -1,43 +1,134 @@ +import type { JwtPayload } from '@clerk/types'; import { describe, expect, it } from 'vitest'; +import { mockTokens, mockVerificationResults } from '../../fixtures/machine'; +import type { AuthenticateContext } from '../../tokens/authenticateContext'; import { handshake, signedIn, signedOut } from '../authStatus'; describe('signed-in', () => { - it('does not include debug headers', () => { - const authObject = signedIn({} as any, {} as any, undefined, 'token'); + describe('session tokens', () => { + it('does not include debug headers', () => { + const authObject = signedIn({ + tokenType: 'session_token', + authenticateContext: {} as AuthenticateContext, + sessionClaims: {} as JwtPayload, + token: 'token', + }); - expect(authObject.headers.get('x-clerk-auth-status')).toBeNull(); - expect(authObject.headers.get('x-clerk-auth-reason')).toBeNull(); - expect(authObject.headers.get('x-clerk-auth-message')).toBeNull(); + expect(authObject.headers.get('x-clerk-auth-status')).toBeNull(); + expect(authObject.headers.get('x-clerk-auth-reason')).toBeNull(); + expect(authObject.headers.get('x-clerk-auth-message')).toBeNull(); + }); + + it('authObject returned by toAuth() returns the token passed', async () => { + const signedInAuthObject = signedIn({ + tokenType: 'session_token', + authenticateContext: {} as AuthenticateContext, + sessionClaims: { sid: 'sid' } as JwtPayload, + token: 'token', + }).toAuth(); + const token = await signedInAuthObject.getToken(); + + expect(token).toBe('token'); + }); }); - it('authObject returned by toAuth() returns the token passed', async () => { - const signedInAuthObject = signedIn({} as any, { sid: 'sid' } as any, undefined, 'token').toAuth(); - const token = await signedInAuthObject.getToken(); + describe('machine auth tokens', () => { + it('does not include debug headers', () => { + const authObject = signedIn({ + tokenType: 'api_key', + authenticateContext: {} as AuthenticateContext, + token: mockTokens.api_key, + machineData: mockVerificationResults.api_key, + }); + + expect(authObject.headers.get('x-clerk-auth-status')).toBeNull(); + expect(authObject.headers.get('x-clerk-auth-reason')).toBeNull(); + expect(authObject.headers.get('x-clerk-auth-message')).toBeNull(); + }); - expect(token).toBe('token'); + it('authObject returned by toAuth() returns the token passed', async () => { + const authObject = signedIn({ + tokenType: 'api_key', + authenticateContext: {} as AuthenticateContext, + token: mockTokens.api_key, + machineData: mockVerificationResults.api_key, + }).toAuth(); + + const token = await authObject.getToken(); + expect(token).toBe('ak_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9='); + }); }); }); describe('signed-out', () => { - it('includes debug headers', () => { - const headers = new Headers({ 'custom-header': 'value' }); - const authObject = signedOut({} as any, 'auth-reason', 'auth-message', headers); + describe('session tokens', () => { + it('includes debug headers', () => { + const headers = new Headers({ 'custom-header': 'value' }); + const authObject = signedOut({ + tokenType: 'session_token', + authenticateContext: {} as AuthenticateContext, + reason: 'auth-reason', + message: 'auth-message', + headers, + }); - expect(authObject.headers.get('custom-header')).toBe('value'); - expect(authObject.headers.get('x-clerk-auth-status')).toBe('signed-out'); - expect(authObject.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); - expect(authObject.headers.get('x-clerk-auth-message')).toBe('auth-message'); + expect(authObject.headers.get('custom-header')).toBe('value'); + expect(authObject.headers.get('x-clerk-auth-status')).toBe('signed-out'); + expect(authObject.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(authObject.headers.get('x-clerk-auth-message')).toBe('auth-message'); + }); + + it('handles debug headers containing invalid unicode characters without throwing', () => { + const headers = new Headers({ 'custom-header': 'value' }); + const authObject = signedOut({ + tokenType: 'session_token', + authenticateContext: {} as AuthenticateContext, + reason: 'auth-reason+RR�56', + message: 'auth-message+RR�56', + headers, + }); + + expect(authObject.headers.get('custom-header')).toBe('value'); + expect(authObject.headers.get('x-clerk-auth-status')).toBe('signed-out'); + expect(authObject.headers.get('x-clerk-auth-reason')).toBeNull(); + expect(authObject.headers.get('x-clerk-auth-message')).toBeNull(); + }); }); - it('handles debug headers containing invalid unicode characters without throwing', () => { - const headers = new Headers({ 'custom-header': 'value' }); - const authObject = signedOut({} as any, 'auth-reason+RR�56', 'auth-message+RR�56', headers); + describe('machine auth tokens', () => { + it('includes debug headers', () => { + const headers = new Headers({ 'custom-header': 'value' }); + const authObject = signedOut({ + tokenType: 'api_key', + authenticateContext: {} as AuthenticateContext, + reason: 'auth-reason', + message: 'auth-message', + headers, + }); + + expect(authObject.headers.get('custom-header')).toBe('value'); + expect(authObject.headers.get('x-clerk-auth-status')).toBe('signed-out'); + expect(authObject.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(authObject.headers.get('x-clerk-auth-message')).toBe('auth-message'); + }); + + it('returns an unauthenticated machine object with toAuth()', async () => { + const signedOutAuthObject = signedOut({ + tokenType: 'machine_token', + authenticateContext: {} as AuthenticateContext, + reason: 'auth-reason', + message: 'auth-message', + }).toAuth(); - expect(authObject.headers.get('custom-header')).toBe('value'); - expect(authObject.headers.get('x-clerk-auth-status')).toBe('signed-out'); - expect(authObject.headers.get('x-clerk-auth-reason')).toBeNull(); - expect(authObject.headers.get('x-clerk-auth-message')).toBeNull(); + const token = await signedOutAuthObject.getToken(); + expect(token).toBeNull(); + expect(signedOutAuthObject.tokenType).toBe('machine_token'); + expect(signedOutAuthObject.id).toBeNull(); + expect(signedOutAuthObject.name).toBeNull(); + expect(signedOutAuthObject.subject).toBeNull(); + expect(signedOutAuthObject.claims).toBeNull(); + }); }); }); diff --git a/packages/backend/src/tokens/__tests__/factory.test.ts b/packages/backend/src/tokens/__tests__/factory.test.ts index 0a32a9e6cde..48436fa11f9 100644 --- a/packages/backend/src/tokens/__tests__/factory.test.ts +++ b/packages/backend/src/tokens/__tests__/factory.test.ts @@ -78,7 +78,6 @@ describe('createAuthenticateRequest({ options, apiClient })', () => { }); const requestState = await authenticateRequest(new Request('http://example.com/'), { - // @ts-expect-error is used to check runtime code apiUrl: 'r-apiUrl', apiVersion: 'r-apiVersion', }); diff --git a/packages/backend/src/tokens/__tests__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts new file mode 100644 index 00000000000..dd84e40d3bb --- /dev/null +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { + API_KEY_PREFIX, + getMachineTokenType, + isMachineToken, + isTokenTypeAccepted, + M2M_TOKEN_PREFIX, + OAUTH_TOKEN_PREFIX, +} from '../machine'; + +describe('isMachineToken', () => { + it('returns true for tokens with M2M prefix', () => { + expect(isMachineToken(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe(true); + }); + + it('returns true for tokens with OAuth prefix', () => { + expect(isMachineToken(`${OAUTH_TOKEN_PREFIX}some-token-value`)).toBe(true); + }); + + it('returns true for tokens with API key prefix', () => { + expect(isMachineToken(`${API_KEY_PREFIX}some-token-value`)).toBe(true); + }); + + it('returns false for tokens without a recognized prefix', () => { + expect(isMachineToken('unknown_prefix_token')).toBe(false); + expect(isMachineToken('session_token_value')).toBe(false); + expect(isMachineToken('jwt_token_value')).toBe(false); + }); + + it('returns false for empty tokens', () => { + expect(isMachineToken('')).toBe(false); + }); +}); + +describe('getMachineTokenType', () => { + it('returns "machine_token" for tokens with M2M prefix', () => { + expect(getMachineTokenType(`${M2M_TOKEN_PREFIX}some-token-value`)).toBe('machine_token'); + }); + + it('returns "oauth_token" for tokens with OAuth prefix', () => { + expect(getMachineTokenType(`${OAUTH_TOKEN_PREFIX}some-token-value`)).toBe('oauth_token'); + }); + + it('returns "api_key" for tokens with API key prefix', () => { + expect(getMachineTokenType(`${API_KEY_PREFIX}some-token-value`)).toBe('api_key'); + }); + + it('throws an error for tokens without a recognized prefix', () => { + expect(() => getMachineTokenType('unknown_prefix_token')).toThrow('Unknown machine token type'); + }); + + it('throws an error for case-sensitive prefix mismatches', () => { + expect(() => getMachineTokenType('M2M_token_value')).toThrow('Unknown machine token type'); + expect(() => getMachineTokenType('OAUTH_token_value')).toThrow('Unknown machine token type'); + expect(() => getMachineTokenType('API_KEY_value')).toThrow('Unknown machine token type'); + }); + + it('throws an error for empty tokens', () => { + expect(() => getMachineTokenType('')).toThrow('Unknown machine token type'); + }); +}); + +describe('isTokenTypeAccepted', () => { + it('accepts any token type', () => { + expect(isTokenTypeAccepted('api_key', 'any')).toBe(true); + expect(isTokenTypeAccepted('machine_token', 'any')).toBe(true); + expect(isTokenTypeAccepted('oauth_token', 'any')).toBe(true); + expect(isTokenTypeAccepted('session_token', 'any')).toBe(true); + }); + + it('accepts a list of token types', () => { + expect(isTokenTypeAccepted('api_key', ['api_key', 'machine_token'])).toBe(true); + expect(isTokenTypeAccepted('session_token', ['api_key', 'machine_token'])).toBe(false); + }); + + it('rejects a mismatching token type', () => { + expect(isTokenTypeAccepted('api_key', 'machine_token')).toBe(false); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index d19bbfc5bbb..26b26fec511 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1,7 +1,7 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; -import { TokenVerificationErrorReason } from '../../errors'; +import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors'; import { mockExpiredJwt, mockInvalidSignatureJwt, @@ -10,11 +10,13 @@ import { mockJwtPayload, mockMalformedJwt, } from '../../fixtures'; +import { mockMachineAuthResponses, mockTokens, mockVerificationResults } from '../../fixtures/machine'; import { server } from '../../mock-server'; 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 { AuthenticateRequestOptions } from '../types'; const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; @@ -26,6 +28,10 @@ interface CustomMatchers { toMatchHandshake: (expected: unknown) => R; toBeSignedIn: (expected?: unknown) => R; toBeSignedInToAuth: () => R; + toBeMachineAuthenticated: () => R; + toBeMachineAuthenticatedToAuth: () => R; + toBeMachineUnauthenticated: (expected: unknown) => R; + toBeMachineUnauthenticatedToAuth: (expected: unknown) => R; } declare module 'vitest' { @@ -213,6 +219,73 @@ expect.extend({ }; } }, + toBeMachineAuthenticated(received) { + const pass = received.status === AuthStatus.SignedIn && received.tokenType !== 'session_token'; + if (pass) { + return { + message: () => `expected to be machine authenticated with token type ${received.tokenType}`, + pass: true, + }; + } else { + return { + message: () => `expected to be machine authenticated with token type ${received.tokenType}`, + pass: false, + }; + } + }, + toBeMachineUnauthenticated( + received, + expected: { + tokenType: MachineTokenType; + reason: AuthReason; + message: string; + }, + ) { + const pass = + received.status === AuthStatus.SignedOut && + received.tokenType === expected.tokenType && + received.reason === expected.reason && + received.message === expected.message && + !received.token; + + if (pass) { + return { + message: () => `expected to be machine unauthenticated with token type ${received.tokenType}`, + pass: true, + }; + } else { + return { + message: () => + `expected to be machine unauthenticated with token type ${received.tokenType} but got ${received.status}`, + pass: false, + }; + } + }, + toBeMachineUnauthenticatedToAuth( + received, + expected: { + tokenType: MachineTokenType; + }, + ) { + const pass = + received.tokenType === expected.tokenType && + !received.claims && + !received.subject && + !received.name && + !received.id; + + if (pass) { + return { + message: () => `expected to be machine unauthenticated to auth with token type ${received.tokenType}`, + pass: true, + }; + } else { + return { + message: () => `expected to be machine unauthenticated to auth with token type ${received.tokenType}`, + pass: false, + }; + } + }, }); const defaultHeaders: Record = { @@ -1111,5 +1184,212 @@ describe('tokens.authenticateRequest(options)', () => { ); expect(refreshSession).toHaveBeenCalled(); }); + + test('should default to session_token if acceptsToken is not provided', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json({}, { status: 200 }); + }), + ); + + const result = await authenticateRequest(mockRequestWithHeaderAuth(), mockOptions()); + expect(result.tokenType).toBe('session_token'); + }); + }); + + describe('Machine authentication', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + // Test each token type with parameterized tests + const tokenTypes = ['api_key', 'oauth_token', 'machine_token'] as const; + + describe.each(tokenTypes)('%s Authentication', tokenType => { + const mockToken = mockTokens[tokenType]; + const mockVerification = mockVerificationResults[tokenType]; + const mockConfig = mockMachineAuthResponses[tokenType]; + + test('returns authenticated state with valid token', async () => { + server.use( + http.post(mockConfig.endpoint, () => { + return HttpResponse.json(mockVerification); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockToken}` }); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: tokenType })); + + expect(requestState).toBeMachineAuthenticated(); + }); + + test('returns unauthenticated state with invalid token', async () => { + server.use( + http.post(mockConfig.endpoint, () => { + return HttpResponse.json({}, { status: 404 }); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockToken}` }); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: tokenType })); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType, + reason: MachineTokenVerificationErrorCode.TokenInvalid, + message: `${mockConfig.errorMessage} (code=token-invalid, status=404)`, + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType, + }); + }); + }); + + describe('Any Token Type Authentication', () => { + test.each(tokenTypes)('accepts %s when acceptsToken is "any"', async tokenType => { + const mockToken = mockTokens[tokenType]; + const mockVerification = mockVerificationResults[tokenType]; + const mockConfig = mockMachineAuthResponses[tokenType]; + + server.use( + http.post(mockConfig.endpoint, () => { + return HttpResponse.json(mockVerification); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockToken}` }); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: 'any' })); + + expect(requestState).toBeMachineAuthenticated(); + }); + + test('accepts session token when acceptsToken is "any"', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + return HttpResponse.json(mockJwks); + }), + ); + + const request = mockRequestWithHeaderAuth(); + const requestState = await authenticateRequest(request, mockOptions({ acceptsToken: 'any' })); + + expect(requestState).toBeSignedIn(); + expect(requestState.toAuth()).toBeSignedInToAuth(); + }); + }); + + describe('Token Type Mismatch', () => { + test('returns unauthenticated state when token type mismatches (API key provided, OAuth token expected)', async () => { + const request = mockRequest({ authorization: `Bearer ${mockTokens.api_key}` }); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'oauth_token' })); + + expect(result).toBeMachineUnauthenticated({ + tokenType: 'api_key', + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'api_key', + }); + }); + + test('returns unauthenticated state when token type mismatches (OAuth token provided, M2M token expected)', async () => { + const request = mockRequest({ authorization: `Bearer ${mockTokens.oauth_token}` }); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'machine_token' })); + + expect(result).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'oauth_token', + }); + }); + + test('returns unauthenticated state when token type mismatches (M2M token provided, API key expected)', async () => { + const request = mockRequest({ authorization: `Bearer ${mockTokens.machine_token}` }); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'api_key' })); + + expect(result).toBeMachineUnauthenticated({ + tokenType: 'machine_token', + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'machine_token', + }); + }); + + test('returns unauthenticated state when session token is provided but machine token is expected', async () => { + const request = mockRequestWithHeaderAuth(); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'machine_token' })); + + expect(result).toBeMachineUnauthenticated({ + tokenType: 'machine_token', + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + expect(result.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'machine_token', + }); + }); + }); + + describe('Array of Accepted Token Types', () => { + test('accepts token when it is in the acceptsToken array', async () => { + server.use( + http.post(mockMachineAuthResponses.api_key.endpoint, () => { + return HttpResponse.json(mockVerificationResults.api_key); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockTokens.api_key}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken: ['api_key', 'oauth_token'] }), + ); + + expect(requestState).toBeMachineAuthenticated(); + }); + + test('returns unauthenticated state when token type is not in the acceptsToken array', async () => { + const request = mockRequest({ authorization: `Bearer ${mockTokens.machine_token}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken: ['api_key', 'oauth_token'] }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'machine_token', + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'machine_token', + }); + }); + }); + + describe('Token Location Validation', () => { + test.each(tokenTypes)('returns unauthenticated state when %s is in cookie instead of header', async tokenType => { + const mockToken = mockTokens[tokenType]; + + const requestState = await authenticateRequest( + mockRequestWithCookies( + {}, + { + __session: mockToken, + }, + ), + mockOptions({ acceptsToken: tokenType }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType, + reason: 'No token in header', + message: '', + }); + }); + }); }); }); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 895f948b064..c62641c8381 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -1,9 +1,11 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { APIKey, IdPOAuthAccessToken, MachineToken } from '../../api'; import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; +import { mockVerificationResults } from '../../fixtures/machine'; import { server, validateHeaders } from '../../mock-server'; -import { verifyToken } from '../verify'; +import { verifyMachineAuthToken, verifyToken } from '../verify'; describe('tokens.verify(token, options)', () => { beforeEach(() => { @@ -53,3 +55,233 @@ describe('tokens.verify(token, options)', () => { expect(data).toEqual(mockJwtPayload); }); }); + +describe('tokens.verifyMachineAuthToken(token, options)', () => { + beforeEach(() => { + vi.useFakeTimers(); + const now = new Date(2023, 0, 1).getTime(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + server.resetHandlers(); + }); + + it('verifies provided API key', async () => { + const token = 'ak_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9='; + + server.use( + http.post( + 'https://api.clerk.test/api_keys/verify', + validateHeaders(() => { + return HttpResponse.json(mockVerificationResults.api_key); + }), + ), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('api_key'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + + const data = result.data as APIKey; + expect(data.id).toBe('ak_ey966f1b1xf93586b2debdcadb0b3bd1'); + expect(data.name).toBe('my-api-key'); + expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(data.scopes).toEqual(['read:foo', 'write:bar']); + expect(data.claims).toEqual({ foo: 'bar' }); + }); + + it('verifies provided Machine token', async () => { + const token = 'mt_8XOIucKvqHVr5tYP123456789abcdefghij'; + + server.use( + http.post( + 'https://api.clerk.test/m2m_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(mockVerificationResults.machine_token); + }), + ), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('machine_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + + const data = result.data as MachineToken; + expect(data.id).toBe('mt_ey966f1b1xf93586b2debdcadb0b3bd1'); + expect(data.name).toBe('my-machine-token'); + expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(data.scopes).toEqual(['read:foo', 'write:bar']); + expect(data.claims).toEqual({ foo: 'bar' }); + }); + + it('verifies provided OAuth token', async () => { + const token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; + + server.use( + http.post( + 'https://api.clerk.test/oauth_applications/access_tokens/verify', + validateHeaders(() => { + return HttpResponse.json(mockVerificationResults.oauth_token); + }), + ), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeDefined(); + expect(result.errors).toBeUndefined(); + + const data = result.data as IdPOAuthAccessToken; + expect(data.id).toBe('oat_2VTWUzvGC5UhdJCNx6xG1D98edc'); + expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(data.scopes).toEqual(['read:foo', 'write:bar']); + }); + + describe('handles API errors for API keys', () => { + it('handles invalid token', async () => { + const token = 'ak_invalid_token'; + + server.use( + http.post('https://api.clerk.test/api_keys/verify', () => { + return HttpResponse.json({}, { status: 404 }); + }), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('api_key'); + expect(result.data).toBeUndefined(); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toBe('API key not found'); + expect(result.errors?.[0].code).toBe('token-invalid'); + }); + + it('handles unexpected error', async () => { + const token = 'ak_ey966f1b1xf93586b2debdcadb0b3bd1'; + + server.use( + http.post('https://api.clerk.test/api_keys/verify', () => { + return HttpResponse.json({}, { status: 500 }); + }), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('api_key'); + expect(result.data).toBeUndefined(); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toBe('Unexpected error'); + expect(result.errors?.[0].code).toBe('unexpected-error'); + }); + }); + + describe('handles API errors for M2M tokens', () => { + it('handles invalid token', async () => { + const token = 'mt_invalid_token'; + + server.use( + http.post('https://api.clerk.test/m2m_tokens/verify', () => { + return HttpResponse.json({}, { status: 404 }); + }), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('machine_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toBe('Machine token not found'); + expect(result.errors?.[0].code).toBe('token-invalid'); + }); + + it('handles unexpected error', async () => { + const token = 'mt_ey966f1b1xf93586b2debdcadb0b3bd1'; + + server.use( + http.post('https://api.clerk.test/m2m_tokens/verify', () => { + return HttpResponse.json({}, { status: 500 }); + }), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('machine_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toBe('Unexpected error'); + expect(result.errors?.[0].code).toBe('unexpected-error'); + }); + }); + + describe('handles API errors for OAuth tokens', () => { + it('handles invalid token', async () => { + const token = 'oat_invalid_token'; + + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => { + return HttpResponse.json({}, { status: 404 }); + }), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toBe('OAuth token not found'); + expect(result.errors?.[0].code).toBe('token-invalid'); + }); + + it('handles unexpected error', async () => { + const token = 'oat_8XOIucKvqHVr5tYP123456789abcdefghij'; + + server.use( + http.post('https://api.clerk.test/oauth_applications/access_tokens/verify', () => { + return HttpResponse.json({}, { status: 500 }); + }), + ); + + const result = await verifyMachineAuthToken(token, { + apiUrl: 'https://api.clerk.test', + secretKey: 'a-valid-key', + }); + + expect(result.tokenType).toBe('oauth_token'); + expect(result.data).toBeUndefined(); + expect(result.errors).toBeDefined(); + expect(result.errors?.[0].message).toBe('Unexpected error'); + expect(result.errors?.[0].code).toBe('unexpected-error'); + }); + }); +}); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 2b7ba218506..48814b21d26 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -9,13 +9,18 @@ import type { SharedSignedInAuthObjectProperties, } from '@clerk/types'; -import type { CreateBackendApiOptions } from '../api'; +import type { APIKey, CreateBackendApiOptions, MachineToken } from '../api'; import { createBackendApiClient } from '../api'; import type { AuthenticateContext } from './authenticateContext'; +import type { MachineTokenType, SessionTokenType } from './tokenTypes'; +import { TokenType } from './tokenTypes'; +import type { MachineAuthType } from './types'; type AuthObjectDebugData = Record; type AuthObjectDebug = () => AuthObjectDebugData; +type Claims = Record; + /** * @internal */ @@ -27,6 +32,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & { * @internal */ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { + tokenType: SessionTokenType; getToken: ServerGetToken; has: CheckAuthorizationFromSessionClaims; debug: AuthObjectDebug; @@ -40,6 +46,7 @@ export type SignedOutAuthObject = { sessionId: null; sessionStatus: SessionStatusClaim | null; actor: null; + tokenType: SessionTokenType; userId: null; orgId: null; orgRole: null; @@ -56,10 +63,60 @@ export type SignedOutAuthObject = { debug: AuthObjectDebug; }; +/** + * Extended properties specific to each machine token type. + * While all machine token types share common properties (id, name, subject, etc), + * this type defines the additional properties that are unique to each token type. + * + * @example + * api_key & machine_token: adds `claims` property + * oauth_token: adds no additional properties (empty object) + * + * @template TAuthenticated - Whether the machine object is authenticated or not + */ +type MachineObjectExtendedProperties = { + api_key: { + name: TAuthenticated extends true ? string : null; + claims: TAuthenticated extends true ? Claims | null : null; + }; + machine_token: { + name: TAuthenticated extends true ? string : null; + claims: TAuthenticated extends true ? Claims | null : null; + }; + oauth_token: object; +}; + +/** + * @internal + */ +export type AuthenticatedMachineObject = { + id: string; + subject: string; + scopes: string[]; + getToken: () => Promise; + has: CheckAuthorizationFromSessionClaims; + debug: AuthObjectDebug; + tokenType: T; +} & MachineObjectExtendedProperties[T]; + /** * @internal */ -export type AuthObject = SignedInAuthObject | SignedOutAuthObject; +export type UnauthenticatedMachineObject = { + id: null; + subject: null; + scopes: null; + getToken: () => Promise; + has: CheckAuthorizationFromSessionClaims; + debug: AuthObjectDebug; + tokenType: T; +} & MachineObjectExtendedProperties[T]; + +export type AuthObject = + | SignedInAuthObject + | SignedOutAuthObject + | AuthenticatedMachineObject + | UnauthenticatedMachineObject; const createDebug = (data: AuthObjectDebugData | undefined) => { return () => { @@ -87,6 +144,7 @@ export function signedInAuthObject( fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt, }); return { + tokenType: TokenType.SessionToken, actor, sessionClaims, sessionId, @@ -119,6 +177,7 @@ export function signedOutAuthObject( initialSessionStatus?: SessionStatusClaim, ): SignedOutAuthObject { return { + tokenType: TokenType.SessionToken, sessionClaims: null, sessionId: null, sessionStatus: initialSessionStatus ?? null, @@ -135,6 +194,104 @@ export function signedOutAuthObject( }; } +/** + * @internal + */ +export function authenticatedMachineObject( + tokenType: T, + token: string, + verificationResult: MachineAuthType, + debugData?: AuthObjectDebugData, +): AuthenticatedMachineObject { + const baseObject = { + id: verificationResult.id, + subject: verificationResult.subject, + getToken: () => Promise.resolve(token), + has: () => false, + debug: createDebug(debugData), + }; + + // Type assertions are safe here since we know the verification result type matches the tokenType. + // We need these assertions because TS can't infer the specific type + // just from the tokenType discriminator. + + switch (tokenType) { + case TokenType.ApiKey: { + const result = verificationResult as APIKey; + return { + ...baseObject, + tokenType, + name: result.name, + claims: result.claims, + scopes: result.scopes, + }; + } + case TokenType.MachineToken: { + const result = verificationResult as MachineToken; + return { + ...baseObject, + tokenType, + name: result.name, + claims: result.claims, + scopes: result.scopes, + }; + } + case TokenType.OAuthToken: { + return { + ...baseObject, + tokenType, + scopes: verificationResult.scopes, + } as AuthenticatedMachineObject; + } + default: + throw new Error(`Invalid token type: ${tokenType}`); + } +} + +/** + * @internal + */ +export function unauthenticatedMachineObject( + tokenType: T, + debugData?: AuthObjectDebugData, +): UnauthenticatedMachineObject { + const baseObject = { + id: null, + subject: null, + scopes: null, + has: () => false, + getToken: () => Promise.resolve(null), + debug: createDebug(debugData), + }; + + switch (tokenType) { + case TokenType.ApiKey: { + return { + ...baseObject, + tokenType, + name: null, + claims: null, + }; + } + case TokenType.MachineToken: { + return { + ...baseObject, + tokenType, + name: null, + claims: null, + }; + } + case TokenType.OAuthToken: { + return { + ...baseObject, + tokenType, + } as UnauthenticatedMachineObject; + } + default: + throw new Error(`Invalid token type: ${tokenType}`); + } +} + /** * Auth objects moving through the server -> client boundary need to be serializable * as we need to ensure that they can be transferred via the network as pure strings. diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 6ea02640f65..0699933e81c 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -3,8 +3,21 @@ import type { JwtPayload, PendingSessionOptions } from '@clerk/types'; import { constants } from '../constants'; import type { TokenVerificationErrorReason } from '../errors'; import type { AuthenticateContext } from './authenticateContext'; -import type { SignedInAuthObject, SignedOutAuthObject } from './authObjects'; -import { signedInAuthObject, signedOutAuthObject } from './authObjects'; +import type { + AuthenticatedMachineObject, + SignedInAuthObject, + SignedOutAuthObject, + UnauthenticatedMachineObject, +} from './authObjects'; +import { + authenticatedMachineObject, + signedInAuthObject, + signedOutAuthObject, + unauthenticatedMachineObject, +} from './authObjects'; +import type { MachineTokenType, SessionTokenType } from './tokenTypes'; +import { TokenType } from './tokenTypes'; +import type { MachineAuthType } from './types'; export const AuthStatus = { SignedIn: 'signed-in', @@ -14,7 +27,15 @@ export const AuthStatus = { export type AuthStatus = (typeof AuthStatus)[keyof typeof AuthStatus]; -export type SignedInState = { +type ToAuth = T extends SessionTokenType + ? Authenticated extends true + ? (opts?: PendingSessionOptions) => SignedInAuthObject + : () => SignedOutAuthObject + : Authenticated extends true + ? () => AuthenticatedMachineObject> + : () => UnauthenticatedMachineObject>; + +export type AuthenticatedState = { status: typeof AuthStatus.SignedIn; reason: null; message: null; @@ -26,16 +47,21 @@ export type SignedInState = { signUpUrl: string; afterSignInUrl: string; afterSignUpUrl: string; + /** + * @deprecated Use `isAuthenticated` instead. + */ isSignedIn: true; - toAuth: (opts?: PendingSessionOptions) => SignedInAuthObject | SignedOutAuthObject; + isAuthenticated: true; headers: Headers; token: string; + tokenType: T; + toAuth: ToAuth; }; -export type SignedOutState = { +export type UnauthenticatedState = { status: typeof AuthStatus.SignedOut; - message: string; reason: AuthReason; + message: string; proxyUrl?: string; publishableKey: string; isSatellite: boolean; @@ -44,18 +70,34 @@ export type SignedOutState = { signUpUrl: string; afterSignInUrl: string; afterSignUpUrl: string; + /** + * @deprecated Use `isAuthenticated` instead. + */ isSignedIn: false; - toAuth: () => SignedOutAuthObject; + isAuthenticated: false; + tokenType: T; headers: Headers; token: null; + toAuth: ToAuth; }; -export type HandshakeState = Omit & { +export type HandshakeState = Omit, 'status' | 'toAuth' | 'tokenType'> & { + tokenType: SessionTokenType; status: typeof AuthStatus.Handshake; headers: Headers; toAuth: () => null; }; +/** + * @deprecated Use AuthenticatedState instead + */ +export type SignedInState = AuthenticatedState; + +/** + * @deprecated Use UnauthenticatedState instead + */ +export type SignedOutState = UnauthenticatedState; + export const AuthErrorReason = { ClientUATWithoutSessionToken: 'client-uat-but-no-session-token', DevBrowserMissing: 'dev-browser-missing', @@ -70,6 +112,7 @@ export const AuthErrorReason = { SessionTokenIatInTheFuture: 'session-token-iat-in-the-future', SessionTokenWithoutClientUAT: 'session-token-but-no-client-uat', ActiveOrganizationMismatch: 'active-organization-mismatch', + TokenTypeMismatch: 'token-type-mismatch', UnexpectedError: 'unexpected-error', } as const; @@ -77,15 +120,41 @@ export type AuthErrorReason = (typeof AuthErrorReason)[keyof typeof AuthErrorRea export type AuthReason = AuthErrorReason | TokenVerificationErrorReason; -export type RequestState = SignedInState | SignedOutState | HandshakeState; +export type RequestState = + | AuthenticatedState + | UnauthenticatedState + | (T extends SessionTokenType ? HandshakeState : never); + +type BaseSignedInParams = { + authenticateContext: AuthenticateContext; + headers?: Headers; + token: string; + tokenType: TokenType; +}; + +type SignedInParams = + | (BaseSignedInParams & { tokenType: SessionTokenType; sessionClaims: JwtPayload }) + | (BaseSignedInParams & { tokenType: MachineTokenType; machineData: MachineAuthType }); + +export function signedIn(params: SignedInParams & { tokenType: T }): AuthenticatedState { + const { authenticateContext, headers = new Headers(), token } = params; + + const toAuth = (({ treatPendingAsSignedOut = true } = {}) => { + if (params.tokenType === TokenType.SessionToken) { + const { sessionClaims } = params as { sessionClaims: JwtPayload }; + const authObject = signedInAuthObject(authenticateContext, token, sessionClaims); + + if (treatPendingAsSignedOut && authObject.sessionStatus === 'pending') { + return signedOutAuthObject(undefined, authObject.sessionStatus); + } + + return authObject; + } + + const { machineData } = params as { machineData: MachineAuthType }; + return authenticatedMachineObject(params.tokenType, token, machineData, authenticateContext); + }) as ToAuth; -export function signedIn( - authenticateContext: AuthenticateContext, - sessionClaims: JwtPayload, - headers: Headers = new Headers(), - token: string, -): SignedInState { - const authObject = signedInAuthObject(authenticateContext, token, sessionClaims); return { status: AuthStatus.SignedIn, reason: null, @@ -99,24 +168,30 @@ export function signedIn( afterSignInUrl: authenticateContext.afterSignInUrl || '', afterSignUpUrl: authenticateContext.afterSignUpUrl || '', isSignedIn: true, - toAuth: ({ treatPendingAsSignedOut = true } = {}) => { - if (treatPendingAsSignedOut && authObject.sessionStatus === 'pending') { - return signedOutAuthObject(undefined, authObject.sessionStatus); - } - - return authObject; - }, + isAuthenticated: true, + tokenType: params.tokenType, + toAuth, headers, token, }; } -export function signedOut( - authenticateContext: AuthenticateContext, - reason: AuthReason, - message = '', - headers: Headers = new Headers(), -): SignedOutState { +type SignedOutParams = Omit & { + reason: AuthReason; + message?: string; +}; + +export function signedOut(params: SignedOutParams & { tokenType: T }): UnauthenticatedState { + const { authenticateContext, headers = new Headers(), reason, message = '', tokenType } = params; + + const toAuth = (() => { + if (tokenType === TokenType.SessionToken) { + return signedOutAuthObject({ ...authenticateContext, status: AuthStatus.SignedOut, reason, message }); + } + + return unauthenticatedMachineObject(tokenType, { reason, message, headers }); + }) as ToAuth; + return withDebugHeaders({ status: AuthStatus.SignedOut, reason, @@ -130,8 +205,10 @@ export function signedOut( afterSignInUrl: authenticateContext.afterSignInUrl || '', afterSignUpUrl: authenticateContext.afterSignUpUrl || '', isSignedIn: false, + isAuthenticated: false, + tokenType, + toAuth, headers, - toAuth: () => signedOutAuthObject({ ...authenticateContext, status: AuthStatus.SignedOut, reason, message }), token: null, }); } @@ -155,13 +232,17 @@ export function handshake( afterSignInUrl: authenticateContext.afterSignInUrl || '', afterSignUpUrl: authenticateContext.afterSignUpUrl || '', isSignedIn: false, - headers, + isAuthenticated: false, + tokenType: TokenType.SessionToken, toAuth: () => null, + headers, token: null, }); } -const withDebugHeaders = (requestState: T): T => { +const withDebugHeaders = ( + requestState: T, +): T => { const headers = new Headers(requestState.headers || {}); if (requestState.message) { diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index dc5c3fa03cc..7b3c1d6aa82 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -10,7 +10,7 @@ import type { AuthenticateRequestOptions } from './types'; interface AuthenticateContext extends AuthenticateRequestOptions { // header-based values - sessionTokenInHeader: string | undefined; + tokenInHeader: string | undefined; origin: string | undefined; host: string | undefined; forwardedHost: string | undefined; @@ -50,7 +50,7 @@ class AuthenticateContext implements AuthenticateContext { * @returns {string | undefined} The session token if available, otherwise undefined. */ public get sessionToken(): string | undefined { - return this.sessionTokenInCookie || this.sessionTokenInHeader; + return this.sessionTokenInCookie || this.tokenInHeader; } public constructor( @@ -174,7 +174,7 @@ class AuthenticateContext implements AuthenticateContext { } private initHeaderValues() { - this.sessionTokenInHeader = this.parseAuthorizationHeader(this.getHeader(constants.Headers.Authorization)); + this.tokenInHeader = this.parseAuthorizationHeader(this.getHeader(constants.Headers.Authorization)); this.origin = this.getHeader(constants.Headers.Origin); this.host = this.getHeader(constants.Headers.Host); this.forwardedHost = this.getHeader(constants.Headers.ForwardedHost); diff --git a/packages/backend/src/tokens/factory.ts b/packages/backend/src/tokens/factory.ts index c6369d2baf4..0bc9cf3c4ae 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -1,5 +1,6 @@ import type { ApiClient } from '../api'; import { mergePreDefinedOptions } from '../util/mergePreDefinedOptions'; +import type { AuthenticateRequest } from './request'; import { authenticateRequest as authenticateRequestOriginal, debugRequestState } from './request'; import type { AuthenticateRequestOptions } from './types'; @@ -46,7 +47,7 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options); const apiClient = params.apiClient; - const authenticateRequest = (request: Request, options: RunTimeOptions = {}) => { + const authenticateRequest: AuthenticateRequest = (request: Request, options: RunTimeOptions = {}) => { const { apiUrl, apiVersion } = buildTimeOptions; const runTimeOptions = mergePreDefinedOptions(buildTimeOptions, options); return authenticateRequestOriginal(request, { diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 57654efec20..55ad3e39fce 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -9,6 +9,7 @@ import { AuthErrorReason, signedIn, signedOut } from './authStatus'; import { getCookieName, getCookieValue } from './cookie'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; import type { OrganizationMatcher } from './organizationMatcher'; +import { TokenType } from './tokenTypes'; import type { OrganizationSyncOptions, OrganizationSyncTarget } from './types'; import type { VerifyTokenOptions } from './verify'; import { verifyToken } from './verify'; @@ -220,12 +221,24 @@ export class HandshakeService { } if (sessionToken === '') { - return signedOut(this.authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext: this.authenticateContext, + reason: AuthErrorReason.SessionTokenMissing, + message: '', + headers, + }); } const { data, errors: [error] = [] } = await verifyToken(sessionToken, this.authenticateContext); if (data) { - return signedIn(this.authenticateContext, data, headers, sessionToken); + return signedIn({ + tokenType: TokenType.SessionToken, + authenticateContext: this.authenticateContext, + sessionClaims: data, + headers, + token: sessionToken, + }); } if ( @@ -258,7 +271,13 @@ ${developmentError.getFullMessage()}`, clockSkewInMs: 86_400_000, }); if (retryResult) { - return signedIn(this.authenticateContext, retryResult, headers, sessionToken); + return signedIn({ + tokenType: TokenType.SessionToken, + authenticateContext: this.authenticateContext, + sessionClaims: retryResult, + headers, + token: sessionToken, + }); } throw new Error(retryError?.message || 'Clerk: Handshake retry failed.'); diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts new file mode 100644 index 00000000000..33ad67e21f9 --- /dev/null +++ b/packages/backend/src/tokens/machine.ts @@ -0,0 +1,69 @@ +import type { AuthenticateRequestOptions } from '../tokens/types'; +import type { MachineTokenType } from './tokenTypes'; +import { TokenType } from './tokenTypes'; + +export const M2M_TOKEN_PREFIX = 'mt_'; +export const OAUTH_TOKEN_PREFIX = 'oat_'; +export const API_KEY_PREFIX = 'ak_'; + +const MACHINE_TOKEN_PREFIXES = [M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX, API_KEY_PREFIX] as const; + +/** + * Checks if a token is a machine token by looking at its prefix. + * + * @remarks + * In the future, this will support custom prefixes that can be prepended to the base prefixes + * (e.g. "org_a_m2m_", "org_a_oauth_access_", "org_a_api_key_") + * + * @param token - The token string to check + * @returns true if the token starts with a recognized machine token prefix + */ +export function isMachineToken(token: string): boolean { + return MACHINE_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix)); +} + +/** + * Gets the specific type of machine token based on its prefix. + * + * @remarks + * In the future, this will support custom prefixes that can be prepended to the base prefixes + * (e.g. "org_a_m2m_", "org_a_oauth_access_", "org_a_api_key_") + * + * @param token - The token string to check + * @returns The specific MachineTokenType + * @throws Error if the token doesn't match any known machine token prefix + */ +export function getMachineTokenType(token: string): MachineTokenType { + if (token.startsWith(M2M_TOKEN_PREFIX)) { + return TokenType.MachineToken; + } + + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + return TokenType.OAuthToken; + } + + if (token.startsWith(API_KEY_PREFIX)) { + return TokenType.ApiKey; + } + + throw new Error('Unknown machine token type'); +} + +/** + * Check if a token type is accepted given a requested token type or list of token types. + * + * @param tokenType - The token type to check + * @param acceptsToken - The requested token type or list of token types + * @returns true if the token type is accepted + */ +export const isTokenTypeAccepted = ( + tokenType: TokenType, + acceptsToken: NonNullable, +): boolean => { + if (acceptsToken === 'any') { + return true; + } + + const tokenTypes = Array.isArray(acceptsToken) ? acceptsToken : [acceptsToken]; + return tokenTypes.includes(tokenType); +}; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 587797258f3..8b9143c1487 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -2,21 +2,24 @@ import type { JwtPayload } from '@clerk/types'; import { constants } from '../constants'; import type { TokenCarrier } from '../errors'; -import { TokenVerificationError, TokenVerificationErrorReason } from '../errors'; +import { MachineTokenVerificationError, TokenVerificationError, TokenVerificationErrorReason } from '../errors'; import { decodeJwt } from '../jwt/verifyJwt'; import { assertValidSecretKey } from '../util/optionsAssertions'; import { isDevelopmentFromSecretKey } from '../util/shared'; import type { AuthenticateContext } from './authenticateContext'; import { createAuthenticateContext } from './authenticateContext'; import type { SignedInAuthObject } from './authObjects'; -import type { HandshakeState, RequestState, SignedInState, SignedOutState } from './authStatus'; +import type { HandshakeState, RequestState, SignedInState, SignedOutState, UnauthenticatedState } from './authStatus'; import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus'; import { createClerkRequest } from './clerkRequest'; import { getCookieName, getCookieValue } from './cookie'; import { HandshakeService } from './handshake'; +import { getMachineTokenType, isMachineToken, isTokenTypeAccepted } from './machine'; import { OrganizationMatcher } from './organizationMatcher'; +import type { MachineTokenType, SessionTokenType } from './tokenTypes'; +import { TokenType } from './tokenTypes'; import type { AuthenticateRequestOptions } from './types'; -import { verifyToken } from './verify'; +import { verifyMachineAuthToken, verifyToken } from './verify'; export const RefreshTokenErrorReason = { NonEligibleNoCookie: 'non-eligible-no-refresh-cookie', @@ -69,13 +72,64 @@ function isRequestEligibleForRefresh( ); } -export async function authenticateRequest( +function checkTokenTypeMismatch( + parsedTokenType: MachineTokenType, + acceptsToken: NonNullable, + authenticateContext: AuthenticateContext, +): UnauthenticatedState | null { + const mismatch = !isTokenTypeAccepted(parsedTokenType, acceptsToken); + if (mismatch) { + return signedOut({ + tokenType: parsedTokenType, + authenticateContext, + reason: AuthErrorReason.TokenTypeMismatch, + }); + } + return null; +} + +export interface AuthenticateRequest { + /** + * @example + * clerkClient.authenticateRequest(request, { acceptsToken: ['session_token', 'api_key'] }); + */ + ( + request: Request, + options: AuthenticateRequestOptions & { acceptsToken: T }, + ): Promise>; + + /** + * @example + * clerkClient.authenticateRequest(request, { acceptsToken: 'session_token' }); + */ + ( + request: Request, + options: AuthenticateRequestOptions & { acceptsToken: T }, + ): Promise>; + + /** + * @example + * clerkClient.authenticateRequest(request, { acceptsToken: 'any' }); + */ + (request: Request, options: AuthenticateRequestOptions & { acceptsToken: 'any' }): Promise>; + + /** + * @example + * clerkClient.authenticateRequest(request); + */ + (request: Request, options?: AuthenticateRequestOptions): Promise>; +} + +export const authenticateRequest: AuthenticateRequest = (async ( request: Request, options: AuthenticateRequestOptions, -): Promise { +): Promise> => { const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options); assertValidSecretKey(authenticateContext.secretKey); + // Default tokenType is session_token for backwards compatibility. + const acceptsToken = options.acceptsToken ?? TokenType.SessionToken; + if (authenticateContext.isSatellite) { assertSignInUrlExists(authenticateContext.signInUrl, authenticateContext.secretKey); if (authenticateContext.signInUrl && authenticateContext.origin) { @@ -228,7 +282,12 @@ export async function authenticateRequest( headers?: Headers, ): SignedInState | SignedOutState | HandshakeState { if (!handshakeService.isRequestEligibleForHandshake()) { - return signedOut(authenticateContext, reason, message); + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason, + message, + }); } // Right now the only usage of passing in different headers is for multi-domain sync, which redirects somewhere else. @@ -248,7 +307,12 @@ export async function authenticateRequest( if (isRedirectLoop) { const msg = `Clerk: Refreshing the session token resulted in an infinite redirect loop. This usually means that your Clerk instance keys do not match - make sure to copy the correct publishable and secret keys from the Clerk dashboard.`; console.log(msg); - return signedOut(authenticateContext, reason, message); + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason, + message, + }); } return handshake(authenticateContext, reason, message, handshakeHeaders); @@ -312,19 +376,25 @@ export async function authenticateRequest( } async function authenticateRequestWithTokenInHeader() { - const { sessionTokenInHeader } = authenticateContext; + const { tokenInHeader } = authenticateContext; try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { data, errors } = await verifyToken(sessionTokenInHeader!, authenticateContext); + const { data, errors } = await verifyToken(tokenInHeader!, authenticateContext); if (errors) { throw errors[0]; } // use `await` to force this try/catch handle the signedIn invocation - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return signedIn(authenticateContext, data, undefined, sessionTokenInHeader!); + return signedIn({ + tokenType: TokenType.SessionToken, + authenticateContext, + sessionClaims: data, + headers: new Headers(), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: tokenInHeader!, + }); } catch (err) { - return handleError(err, 'header'); + return handleSessionTokenError(err, 'header'); } } @@ -426,7 +496,11 @@ export async function authenticateRequest( } if (!hasActiveClient && !hasSessionToken) { - return signedOut(authenticateContext, AuthErrorReason.SessionTokenAndUATMissing, ''); + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.SessionTokenAndUATMissing, + }); } // This can eagerly run handshake since client_uat is SameSite=Strict in dev @@ -442,7 +516,7 @@ export async function authenticateRequest( const { data: decodeResult, errors: decodedErrors } = decodeJwt(authenticateContext.sessionTokenInCookie!); if (decodedErrors) { - return handleError(decodedErrors[0], 'cookie'); + return handleSessionTokenError(decodedErrors[0], 'cookie'); } if (decodeResult.payload.iat < authenticateContext.clientUat) { @@ -455,13 +529,15 @@ export async function authenticateRequest( if (errors) { throw errors[0]; } - const signedInRequestState = signedIn( + + const signedInRequestState = signedIn({ + tokenType: TokenType.SessionToken, authenticateContext, - data, - undefined, + sessionClaims: data, + headers: new Headers(), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - authenticateContext.sessionTokenInCookie!, - ); + token: authenticateContext.sessionTokenInCookie!, + }); const authObject = signedInRequestState.toAuth(); // Org sync if necessary @@ -474,18 +550,27 @@ export async function authenticateRequest( return signedInRequestState; } catch (err) { - return handleError(err, 'cookie'); + return handleSessionTokenError(err, 'cookie'); } - return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); + // Unreachable + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.UnexpectedError, + }); } - async function handleError( + async function handleSessionTokenError( err: unknown, tokenCarrier: TokenCarrier, ): Promise { if (!(err instanceof TokenVerificationError)) { - return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.UnexpectedError, + }); } let refreshError: string | null; @@ -493,7 +578,13 @@ export async function authenticateRequest( if (isRequestEligibleForRefresh(err, authenticateContext, request)) { const { data, error } = await attemptRefresh(authenticateContext); if (data) { - return signedIn(authenticateContext, data.jwtPayload, data.headers, data.sessionToken); + return signedIn({ + tokenType: TokenType.SessionToken, + authenticateContext, + sessionClaims: data.jwtPayload, + headers: data.headers, + token: data.sessionToken, + }); } // If there's any error, simply fallback to the handshake flow including the reason as a query parameter. @@ -529,22 +620,142 @@ export async function authenticateRequest( ); } - return signedOut(authenticateContext, err.reason, err.getFullMessage()); + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: err.reason, + message: err.getFullMessage(), + }); } - if (authenticateContext.sessionTokenInHeader) { - return authenticateRequestWithTokenInHeader(); + function handleMachineError(tokenType: MachineTokenType, err: unknown): UnauthenticatedState { + if (!(err instanceof MachineTokenVerificationError)) { + return signedOut({ + tokenType, + authenticateContext, + reason: AuthErrorReason.UnexpectedError, + }); + } + + return signedOut({ + tokenType, + authenticateContext, + reason: err.code, + message: err.getFullMessage(), + }); + } + + async function authenticateMachineRequestWithTokenInHeader() { + const { tokenInHeader } = authenticateContext; + // Use session token error handling if no token in header (default behavior) + if (!tokenInHeader) { + return handleSessionTokenError(new Error('Missing token in header'), 'header'); + } + + // Handle case where tokenType is any and the token is not a machine token + if (!isMachineToken(tokenInHeader)) { + return signedOut({ + tokenType: acceptsToken as MachineTokenType, + authenticateContext, + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + } + + const parsedTokenType = getMachineTokenType(tokenInHeader); + const mismatchState = checkTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); + if (mismatchState) { + return mismatchState; + } + + const { data, tokenType, errors } = await verifyMachineAuthToken(tokenInHeader, authenticateContext); + if (errors) { + return handleMachineError(tokenType, errors[0]); + } + return signedIn({ + tokenType, + authenticateContext, + machineData: data, + token: tokenInHeader, + }); + } + + async function authenticateAnyRequestWithTokenInHeader() { + const { tokenInHeader } = authenticateContext; + // Use session token error handling if no token in header (default behavior) + if (!tokenInHeader) { + return handleSessionTokenError(new Error('Missing token in header'), 'header'); + } + + // Handle as a machine token + if (isMachineToken(tokenInHeader)) { + const parsedTokenType = getMachineTokenType(tokenInHeader); + const mismatchState = checkTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); + if (mismatchState) { + return mismatchState; + } + + const { data, tokenType, errors } = await verifyMachineAuthToken(tokenInHeader, authenticateContext); + if (errors) { + return handleMachineError(tokenType, errors[0]); + } + + return signedIn({ + tokenType, + authenticateContext, + machineData: data, + token: tokenInHeader, + }); + } + + // Handle as a regular session token + const { data, errors } = await verifyToken(tokenInHeader, authenticateContext); + if (errors) { + return handleSessionTokenError(errors[0], 'header'); + } + + return signedIn({ + tokenType: TokenType.SessionToken, + authenticateContext, + sessionClaims: data, + token: tokenInHeader, + }); + } + + if (authenticateContext.tokenInHeader) { + if (acceptsToken === 'any') { + return authenticateAnyRequestWithTokenInHeader(); + } + + if (acceptsToken === TokenType.SessionToken) { + return authenticateRequestWithTokenInHeader(); + } + + return authenticateMachineRequestWithTokenInHeader(); + } + + // Machine requests cannot have the token in the cookie, it must be in header. + if ( + acceptsToken === TokenType.OAuthToken || + acceptsToken === TokenType.ApiKey || + acceptsToken === TokenType.MachineToken + ) { + return signedOut({ + tokenType: acceptsToken, + authenticateContext, + reason: 'No token in header', + }); } return authenticateRequestWithTokenInCookie(); -} +}) as AuthenticateRequest; /** * @internal */ export const debugRequestState = (params: RequestState) => { - const { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain } = params; - return { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain }; + const { isSignedIn, isAuthenticated, proxyUrl, reason, message, publishableKey, isSatellite, domain } = params; + return { isSignedIn, isAuthenticated, proxyUrl, reason, message, publishableKey, isSatellite, domain }; }; const convertTokenVerificationErrorReasonToAuthErrorReason = ({ diff --git a/packages/backend/src/tokens/tokenTypes.ts b/packages/backend/src/tokens/tokenTypes.ts new file mode 100644 index 00000000000..51bf571b2fa --- /dev/null +++ b/packages/backend/src/tokens/tokenTypes.ts @@ -0,0 +1,11 @@ +export const TokenType = { + SessionToken: 'session_token', + ApiKey: 'api_key', + MachineToken: 'machine_token', + OAuthToken: 'oauth_token', +} as const; + +export type TokenType = (typeof TokenType)[keyof typeof TokenType]; + +export type SessionTokenType = typeof TokenType.SessionToken; +export type MachineTokenType = Exclude; diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index ad282609dc6..c65ccf9c41d 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -1,6 +1,7 @@ import type { MatchFunction } from '@clerk/shared/pathToRegexp'; -import type { ApiClient } from '../api'; +import type { ApiClient, APIKey, IdPOAuthAccessToken, MachineToken } from '../api'; +import type { TokenType } from './tokenTypes'; import type { VerifyTokenOptions } from './verify'; export type AuthenticateRequestOptions = { @@ -49,6 +50,11 @@ export type AuthenticateRequestOptions = { * @internal */ apiClient?: ApiClient; + /** + * The type of token to accept. + * @default 'session_token' + */ + acceptsToken?: TokenType | TokenType[] | 'any'; } & VerifyTokenOptions; /** @@ -119,6 +125,8 @@ export type OrganizationSyncOptions = { */ type Pattern = string; +export type MachineAuthType = MachineToken | APIKey | IdPOAuthAccessToken; + export type OrganizationSyncTargetMatchers = { OrganizationMatcher: MatchFunction>> | null; PersonalAccountMatcher: MatchFunction>> | null; diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 104908d286e..88a9a02fa6a 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -1,11 +1,23 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { JwtPayload } from '@clerk/types'; -import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; +import type { APIKey, IdPOAuthAccessToken, MachineToken } from '../api'; +import { createBackendApiClient } from '../api/factory'; +import { + MachineTokenVerificationError, + MachineTokenVerificationErrorCode, + TokenVerificationError, + TokenVerificationErrorAction, + TokenVerificationErrorReason, +} from '../errors'; import type { VerifyJwtOptions } from '../jwt'; -import type { JwtReturnType } from '../jwt/types'; +import type { JwtReturnType, MachineTokenReturnType } from '../jwt/types'; import { decodeJwt, verifyJwt } from '../jwt/verifyJwt'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; +import { API_KEY_PREFIX, M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX } from './machine'; +import type { MachineTokenType } from './tokenTypes'; +import { TokenType } from './tokenTypes'; /** * @interface @@ -119,3 +131,117 @@ export async function verifyToken( return { errors: [error as TokenVerificationError] }; } } + +/** + * Handles errors from Clerk API responses for machine tokens + * @param tokenType - The type of machine token + * @param err - The error from the Clerk API + * @param notFoundMessage - Custom message for 404 errors + */ +function handleClerkAPIError( + tokenType: MachineTokenType, + err: any, + notFoundMessage: string, +): MachineTokenReturnType { + if (isClerkAPIResponseError(err)) { + let code: MachineTokenVerificationErrorCode; + let message: string; + + switch (err.status) { + case 401: + code = MachineTokenVerificationErrorCode.InvalidSecretKey; + message = err.errors[0]?.message || 'Invalid secret key'; + break; + case 404: + code = MachineTokenVerificationErrorCode.TokenInvalid; + message = notFoundMessage; + break; + default: + code = MachineTokenVerificationErrorCode.UnexpectedError; + message = 'Unexpected error'; + } + + return { + data: undefined, + tokenType, + errors: [ + new MachineTokenVerificationError({ + message, + code, + status: err.status, + }), + ], + }; + } + + return { + data: undefined, + tokenType, + errors: [ + new MachineTokenVerificationError({ + message: 'Unexpected error', + code: MachineTokenVerificationErrorCode.UnexpectedError, + status: err.status, + }), + ], + }; +} + +async function verifyMachineToken( + secret: string, + options: VerifyTokenOptions, +): Promise> { + try { + const client = createBackendApiClient(options); + const verifiedToken = await client.machineTokens.verifySecret(secret); + return { data: verifiedToken, tokenType: TokenType.MachineToken, errors: undefined }; + } catch (err: any) { + return handleClerkAPIError(TokenType.MachineToken, err, 'Machine token not found'); + } +} + +async function verifyOAuthToken( + secret: string, + options: VerifyTokenOptions, +): Promise> { + try { + const client = createBackendApiClient(options); + const verifiedToken = await client.idPOAuthAccessToken.verifySecret(secret); + return { data: verifiedToken, tokenType: TokenType.OAuthToken, errors: undefined }; + } catch (err: any) { + return handleClerkAPIError(TokenType.OAuthToken, err, 'OAuth token not found'); + } +} + +async function verifyAPIKey( + secret: string, + options: VerifyTokenOptions, +): Promise> { + try { + const client = createBackendApiClient(options); + const verifiedToken = await client.apiKeys.verifySecret(secret); + return { data: verifiedToken, tokenType: TokenType.ApiKey, errors: undefined }; + } catch (err: any) { + return handleClerkAPIError(TokenType.ApiKey, err, 'API key not found'); + } +} + +/** + * Verifies any type of machine token by detecting its type from the prefix. + * + * @param token - The token to verify (e.g. starts with "m2m_", "oauth_", "api_key_", etc.) + * @param options - Options including secretKey for BAPI authorization + */ +export async function verifyMachineAuthToken(token: string, options: VerifyTokenOptions) { + if (token.startsWith(M2M_TOKEN_PREFIX)) { + return verifyMachineToken(token, options); + } + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + return verifyOAuthToken(token, options); + } + if (token.startsWith(API_KEY_PREFIX)) { + return verifyAPIKey(token, options); + } + + throw new Error('Unknown machine token type'); +} diff --git a/packages/backend/src/util/decorateObjectWithResources.ts b/packages/backend/src/util/decorateObjectWithResources.ts index 4e90ff94ccd..925cb39e4de 100644 --- a/packages/backend/src/util/decorateObjectWithResources.ts +++ b/packages/backend/src/util/decorateObjectWithResources.ts @@ -1,6 +1,6 @@ import type { CreateBackendApiOptions, Organization, Session, User } from '../api'; import { createBackendApiClient } from '../api'; -import type { AuthObject } from '../tokens/authObjects'; +import type { AuthObject, SignedInAuthObject, SignedOutAuthObject } from '../tokens/authObjects'; type DecorateAuthWithResourcesOptions = { loadSession?: boolean; @@ -23,7 +23,7 @@ export const decorateObjectWithResources = async ( opts: CreateBackendApiOptions & DecorateAuthWithResourcesOptions, ): Promise> => { const { loadSession, loadUser, loadOrganization } = opts || {}; - const { userId, sessionId, orgId } = authObj; + const { userId, sessionId, orgId } = authObj as SignedInAuthObject | SignedOutAuthObject; const { sessions, users, organizations } = createBackendApiClient({ ...opts }); diff --git a/packages/express/src/__tests__/helpers.ts b/packages/express/src/__tests__/helpers.ts index 98cb848ce0b..125f0afd2e0 100644 --- a/packages/express/src/__tests__/helpers.ts +++ b/packages/express/src/__tests__/helpers.ts @@ -1,4 +1,4 @@ -import type { AuthObject } from '@clerk/backend'; +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { Application, Request as ExpressRequest, RequestHandler, Response as ExpressResponse } from 'express'; import express from 'express'; import supertest from 'supertest'; @@ -26,7 +26,9 @@ export function mockRequest(): ExpressRequest { return {} as ExpressRequest; } -export function mockRequestWithAuth(auth: Partial = {}): ExpressRequestWithAuth { +export function mockRequestWithAuth( + auth: Partial = {}, +): ExpressRequestWithAuth { return { auth: () => ({ sessionClaims: null, diff --git a/packages/express/src/getAuth.ts b/packages/express/src/getAuth.ts index 29ba37ad478..3411b705c0a 100644 --- a/packages/express/src/getAuth.ts +++ b/packages/express/src/getAuth.ts @@ -1,4 +1,4 @@ -import type { AuthObject } from '@clerk/backend'; +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { PendingSessionOptions } from '@clerk/types'; import type { Request as ExpressRequest } from 'express'; @@ -10,12 +10,11 @@ type GetAuthOptions = PendingSessionOptions; /** * Retrieves the Clerk AuthObject using the current request object. * - * @param {ExpressRequest} req - The current request object. * @param {GetAuthOptions} options - Optional configuration for retriving auth object. * @returns {AuthObject} Object with information about the request state and claims. * @throws {Error} `clerkMiddleware` or `requireAuth` is required to be set in the middleware chain before this util is used. */ -export const getAuth = (req: ExpressRequest, options?: GetAuthOptions): AuthObject => { +export const getAuth = (req: ExpressRequest, options?: GetAuthOptions): SignedInAuthObject | SignedOutAuthObject => { if (!requestHasAuthObject(req)) { throw new Error(middlewareRequired('getAuth')); } diff --git a/packages/express/src/types.ts b/packages/express/src/types.ts index dac1c76de7b..ae3273431cf 100644 --- a/packages/express/src/types.ts +++ b/packages/express/src/types.ts @@ -1,10 +1,10 @@ -import type { AuthObject, createClerkClient } from '@clerk/backend'; -import type { AuthenticateRequestOptions } from '@clerk/backend/internal'; +import type { createClerkClient } from '@clerk/backend'; +import type { AuthenticateRequestOptions, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { PendingSessionOptions } from '@clerk/types'; import type { Request as ExpressRequest } from 'express'; export type ExpressRequestWithAuth = ExpressRequest & { - auth: AuthObject & { (options?: PendingSessionOptions): AuthObject }; + auth: (options?: PendingSessionOptions) => SignedInAuthObject | SignedOutAuthObject; }; export type ClerkMiddlewareOptions = AuthenticateRequestOptions & { diff --git a/packages/fastify/src/getAuth.ts b/packages/fastify/src/getAuth.ts index 7acb04aefc2..1707572dfd6 100644 --- a/packages/fastify/src/getAuth.ts +++ b/packages/fastify/src/getAuth.ts @@ -1,11 +1,11 @@ -import type { AuthObject } from '@clerk/backend'; +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { FastifyRequest } from 'fastify'; import { pluginRegistrationRequired } from './errors'; -type FastifyRequestWithAuth = FastifyRequest & { auth: AuthObject }; +type FastifyRequestWithAuth = FastifyRequest & { auth: SignedInAuthObject | SignedOutAuthObject }; -export const getAuth = (req: FastifyRequest): AuthObject => { +export const getAuth = (req: FastifyRequest): SignedInAuthObject | SignedOutAuthObject => { const authReq = req as FastifyRequestWithAuth; if (!authReq.auth) { diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index bbc6ea5161c..ce808df80f3 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,5 +1,13 @@ import type { AuthObject } from '@clerk/backend'; -import { constants, createClerkRequest, createRedirect, type RedirectFun } from '@clerk/backend/internal'; +import type { + AuthenticatedMachineObject, + AuthenticateRequestOptions, + RedirectFun, + SignedInAuthObject, + SignedOutAuthObject, + UnauthenticatedMachineObject, +} from '@clerk/backend/internal'; +import { constants, createClerkRequest, createRedirect, TokenType } from '@clerk/backend/internal'; import type { PendingSessionOptions } from '@clerk/types'; import { notFound, redirect } from 'next/navigation'; @@ -7,8 +15,10 @@ import { PUBLISHABLE_KEY, SIGN_IN_URL, SIGN_UP_URL } from '../../server/constant import { createAsyncGetAuth } from '../../server/createGetAuth'; import { authAuthHeaderMissing } from '../../server/errors'; import { getAuthKeyFromRequest, getHeader } from '../../server/headers-utils'; +import { unauthorized } from '../../server/nextErrors'; import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; +import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray } from '../../server/types'; import { decryptClerkRequestData } from '../../server/utils'; import { isNextWithUnstableServerActions } from '../../utils/sdk-versions'; import { buildRequestLike } from './utils'; @@ -16,7 +26,7 @@ import { buildRequestLike } from './utils'; /** * `Auth` object of the currently active user and the `redirectToSignIn()` method. */ -type Auth = AuthObject & { +type SessionAuth = (SignedInAuthObject | SignedOutAuthObject) & { /** * The `auth()` helper returns the `redirectToSignIn()` method, which you can use to redirect the user to the sign-in page. * @@ -25,7 +35,7 @@ type Auth = AuthObject & { * > [!NOTE] * > `auth()` on the server-side can only access redirect URLs defined via [environment variables](https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects) or [`clerkMiddleware` dynamic keys](https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys). */ - redirectToSignIn: RedirectFun>; + redirectToSignIn: RedirectFun; /** * The `auth()` helper returns the `redirectToSignUp()` method, which you can use to redirect the user to the sign-up page. @@ -35,11 +45,43 @@ type Auth = AuthObject & { * > [!NOTE] * > `auth()` on the server-side can only access redirect URLs defined via [environment variables](https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects) or [`clerkMiddleware` dynamic keys](https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys). */ - redirectToSignUp: RedirectFun>; + redirectToSignUp: RedirectFun; }; -export interface AuthFn { - (options?: PendingSessionOptions): Promise; +type MachineAuth = (AuthenticatedMachineObject | UnauthenticatedMachineObject) & { + tokenType: T; +}; + +export type AuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; + +export interface AuthFn> { + /** + * @example + * const authObject = await auth({ acceptsToken: ['session_token', 'api_key'] }) + */ + ( + options: AuthOptions & { acceptsToken: T }, + ): Promise, MachineAuth>>; + + /** + * @example + * const authObject = await auth({ acceptsToken: 'session_token' }) + */ + ( + options: AuthOptions & { acceptsToken: T }, + ): Promise, MachineAuth>>; + + /** + * @example + * const authObject = await auth({ acceptsToken: 'any' }) + */ + (options: AuthOptions & { acceptsToken: 'any' }): Promise; + + /** + * @example + * const authObject = await auth() + */ + (options?: PendingSessionOptions): Promise>; /** * `auth` includes a single property, the `protect()` method, which you can use in two ways: @@ -69,7 +111,7 @@ export interface AuthFn { * - Only works on the server-side, such as in Server Components, Route Handlers, and Server Actions. * - Requires [`clerkMiddleware()`](https://clerk.com/docs/references/nextjs/clerk-middleware) to be configured. */ -export const auth: AuthFn = async ({ treatPendingAsSignedOut } = {}) => { +export const auth: AuthFn = (async (options?: AuthOptions) => { // eslint-disable-next-line @typescript-eslint/no-require-imports require('server-only'); @@ -90,7 +132,10 @@ export const auth: AuthFn = async ({ treatPendingAsSignedOut } = {}) => { const authObject = await createAsyncGetAuth({ debugLoggerName: 'auth()', noAuthStatusMessage: authAuthHeaderMissing('auth', await stepsBasedOnSrcDirectory()), - })(request, { treatPendingAsSignedOut }); + })(request, { + treatPendingAsSignedOut: options?.treatPendingAsSignedOut, + acceptsToken: options?.acceptsToken ?? TokenType.SessionToken, + }); const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl'); @@ -111,7 +156,7 @@ export const auth: AuthFn = async ({ treatPendingAsSignedOut } = {}) => { publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY, signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL, signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL, - sessionStatus: authObject.sessionStatus, + sessionStatus: authObject.tokenType === TokenType.SessionToken ? authObject.sessionStatus : null, }), returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(), ] as const; @@ -132,7 +177,7 @@ export const auth: AuthFn = async ({ treatPendingAsSignedOut } = {}) => { }; return Object.assign(authObject, { redirectToSignIn, redirectToSignUp }); -}; +}) as AuthFn; auth.protect = async (...args: any[]) => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -147,6 +192,7 @@ auth.protect = async (...args: any[]) => { redirectToSignIn: authObject.redirectToSignIn, notFound, redirect, + unauthorized, }); return protect(...args); diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 11c0fe1a3a1..238a77e7cdc 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1,6 +1,6 @@ // There is no need to execute the complete authenticateRequest to test clerkMiddleware // This mock SHOULD exist before the import of authenticateRequest -import { AuthStatus, constants } from '@clerk/backend/internal'; +import { AuthStatus, constants, TokenType } from '@clerk/backend/internal'; // used to assert the mock import assert from 'assert'; import type { NextFetchEvent } from 'next/server'; @@ -17,6 +17,7 @@ vi.mock('../clerkClient'); const publishableKey = 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA'; const authenticateRequestMock = vi.fn().mockResolvedValue({ toAuth: () => ({ + tokenType: TokenType.SessionToken, debug: (d: any) => d, }), headers: new Headers(), @@ -107,6 +108,13 @@ describe('ClerkMiddleware type tests', () => { }); }); + it('can be used with a handler that expects a token type', () => { + clerkMiddlewareMock(async auth => { + const { getToken } = await auth({ acceptsToken: TokenType.ApiKey }); + await getToken(); + }); + }); + it('can be used with just an optional options object', () => { clerkMiddlewareMock({ secretKey: '', publishableKey: '' }); clerkMiddlewareMock(); @@ -391,6 +399,7 @@ describe('clerkMiddleware(params)', () => { vi.mocked(clerkClient).mockResolvedValue({ authenticateRequest: vi.fn().mockResolvedValue({ toAuth: () => ({ + tokenType: TokenType.SessionToken, debug: (d: any) => d, }), headers: new Headers(), @@ -429,7 +438,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -452,7 +461,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedIn, headers: new Headers(), - toAuth: () => ({ userId: 'user-id' }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'user-id' }), }); const resp = await clerkMiddleware(async auth => { @@ -464,6 +473,31 @@ describe('clerkMiddleware(params)', () => { expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + it('does not throw when protect is called and the request is authenticated with a machine token', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'api_key_xxxxxxxxxxxxxxxxxx' }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(200); + expect(resp?.headers.get('location')).toBeFalsy(); + expect(resp?.headers.get('WWW-Authenticate')).toBeFalsy(); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + it('throws a not found error when protect is called, the user is signed out, and is not a page request', async () => { const req = mockRequest({ url: '/protected', @@ -475,7 +509,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -487,6 +521,62 @@ describe('clerkMiddleware(params)', () => { expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + it('throws an unauthorized error when protect is called and the machine auth token is invalid', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ + tokenType: TokenType.ApiKey, + id: null, + }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect(resp?.headers.get('WWW-Authenticate')).toBeFalsy(); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('throws an unauthorized error with WWW-Authenticate header when protect is called and the oauth token is invalid', async () => { + const req = mockRequest({ + url: '/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer oauth_token_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ + tokenType: TokenType.OAuthToken, + id: null, + }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect(resp?.headers.get('WWW-Authenticate')).toBe( + 'Bearer resource_metadata="https://clerk.included.katydid-92.lcl.dev/.well-known/oauth-protected-resource"', + ); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + it('throws a not found error when protect is called with RBAC params the user does not fulfill, and is a page request', async () => { const req = mockRequest({ url: '/protected', @@ -498,7 +588,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedIn, headers: new Headers(), - toAuth: () => ({ userId: 'user-id', has: () => false }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'user-id', has: () => false }), }); const resp = await clerkMiddleware(async auth => { @@ -521,7 +611,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -545,7 +635,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedIn, headers: new Headers(), - toAuth: () => ({ userId: 'user-id', has: () => false }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'user-id', has: () => false }), }); const resp = await clerkMiddleware(async auth => { @@ -563,6 +653,76 @@ describe('clerkMiddleware(params)', () => { expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true'); expect((await clerkClient()).authenticateRequest).toBeCalled(); }); + + it('throws an unauthorized error when protect is called with mismatching token types', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer m2m_xxxxxxxxxxxxxxxxxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.MachineToken, id: null }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: TokenType.ApiKey }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('does not throw when protect is called with array of token types and request matches one', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.ApiKey, id: 'api_key_xxxxxxxxxxxxxxxxxx' }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: [TokenType.SessionToken, TokenType.ApiKey] }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(200); + expect(resp?.headers.get('location')).toBeFalsy(); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); + + it('throws a not found error when protect is called with array of token types and request does not match any', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxx', + }), + }); + + authenticateRequestMock.mockResolvedValueOnce({ + publishableKey, + status: AuthStatus.SignedOut, + headers: new Headers(), + toAuth: () => ({ tokenType: TokenType.ApiKey, id: null }), + }); + + const resp = await clerkMiddleware(async auth => { + await auth.protect({ token: [TokenType.SessionToken, TokenType.MachineToken] }); + })(req, {} as NextFetchEvent); + + expect(resp?.status).toEqual(401); + expect((await clerkClient()).authenticateRequest).toBeCalled(); + }); }); describe('auth().redirectToSignIn()', () => { @@ -577,7 +737,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -603,7 +763,7 @@ describe('clerkMiddleware(params)', () => { 'Set-Cookie': 'session=;', 'X-Clerk-Auth': '1', }), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -628,7 +788,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -655,7 +815,7 @@ describe('clerkMiddleware(params)', () => { publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: 'userId', has: () => false }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: 'userId', has: () => false }), }); const resp = await clerkMiddleware(async auth => { @@ -714,7 +874,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -739,7 +899,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(async auth => { @@ -764,7 +924,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f publishableKey, status: AuthStatus.SignedOut, headers: new Headers(), - toAuth: () => ({ userId: null }), + toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }), }); const resp = await clerkMiddleware(() => { diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts new file mode 100644 index 00000000000..ad600ea6135 --- /dev/null +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -0,0 +1,101 @@ +import type { AuthenticatedMachineObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import { constants, verifyMachineAuthToken } from '@clerk/backend/internal'; +import { NextRequest } from 'next/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { getAuthDataFromRequestAsync, getAuthDataFromRequestSync } from '../data/getAuthDataFromRequest'; + +vi.mock('@clerk/backend/internal', async () => { + const actual = await vi.importActual('@clerk/backend/internal'); + return { + ...actual, + verifyMachineAuthToken: vi.fn(), + }; +}); + +type MockRequestParams = { + url: string; + appendDevBrowserCookie?: boolean; + method?: string; + headers?: any; +}; + +const mockRequest = (params: MockRequestParams) => { + const { url, appendDevBrowserCookie = false, method = 'GET', headers = new Headers() } = params; + const headersWithCookie = new Headers(headers); + if (appendDevBrowserCookie) { + headersWithCookie.append('cookie', '__clerk_db_jwt=test_jwt'); + } + return new NextRequest(new URL(url, 'https://www.clerk.com').toString(), { method, headers: headersWithCookie }); +}; + +describe('getAuthDataFromRequestAsync', () => { + it('returns unauthenticated machine object when token type does not match', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer ak_xxx', + }), + }); + + const auth = await getAuthDataFromRequestAsync(req, { + acceptsToken: 'machine_token', + }); + + expect(auth.tokenType).toBe('api_key'); + expect((auth as AuthenticatedMachineObject).id).toBeNull(); + }); + + it('returns authenticated machine object when token type matches', async () => { + vi.mocked(verifyMachineAuthToken).mockResolvedValueOnce({ + data: { id: 'ak_123' } as any, + tokenType: 'api_key', + errors: undefined, + }); + + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer ak_xxx', + }), + }); + + const auth = await getAuthDataFromRequestAsync(req, { + acceptsToken: 'api_key', + }); + + expect(auth.tokenType).toBe('api_key'); + expect((auth as AuthenticatedMachineObject).id).toBe('ak_123'); + }); + + it('falls back to session token handling', async () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer session_xxx', + }), + }); + + const auth = await getAuthDataFromRequestAsync(req); + expect(auth.tokenType).toBe('session_token'); + expect((auth as SignedOutAuthObject).userId).toBeNull(); + }); +}); + +describe('getAuthDataFromRequestSync', () => { + it('only accepts session tokens', () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer api_key_xxx', + }), + }); + + const auth = getAuthDataFromRequestSync(req, { + acceptsToken: 'api_key', + }); + + expect(auth.tokenType).toBe('session_token'); + expect(auth.userId).toBeNull(); + }); +}); diff --git a/packages/nextjs/src/server/buildClerkProps.ts b/packages/nextjs/src/server/buildClerkProps.ts index cfbd4686d23..a8da66f78e6 100644 --- a/packages/nextjs/src/server/buildClerkProps.ts +++ b/packages/nextjs/src/server/buildClerkProps.ts @@ -1,7 +1,7 @@ import type { AuthObject, Organization, Session, User } from '@clerk/backend'; import { makeAuthObjectSerializable, stripPrivateDataFromObject } from '@clerk/backend/internal'; -import { getAuthDataFromRequest } from './data/getAuthDataFromRequest'; +import { getAuthDataFromRequestSync } from './data/getAuthDataFromRequest'; import type { RequestLike } from './types'; type BuildClerkPropsInitState = { user?: User | null; session?: Session | null; organization?: Organization | null }; @@ -59,7 +59,7 @@ export const buildClerkProps: BuildClerkProps = (req, initialState = {}) => { }; export function getDynamicAuthData(req: RequestLike, initialState = {}) { - const authObject = getAuthDataFromRequest(req); + const authObject = getAuthDataFromRequestSync(req); return makeAuthObjectSerializable(stripPrivateDataFromObject({ ...authObject, ...initialState })) as AuthObject; } diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 95a9d22a8a9..9dd32a2d49b 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,12 +1,30 @@ import type { AuthObject, ClerkClient } from '@clerk/backend'; -import type { AuthenticateRequestOptions, ClerkRequest, RedirectFun, RequestState } from '@clerk/backend/internal'; -import { AuthStatus, constants, createClerkRequest, createRedirect } from '@clerk/backend/internal'; +import type { + AuthenticateRequestOptions, + ClerkRequest, + RedirectFun, + RequestState, + SignedInAuthObject, + SignedOutAuthObject, +} from '@clerk/backend/internal'; +import { + AuthStatus, + constants, + createClerkRequest, + createRedirect, + isMachineToken, + isTokenTypeAccepted, + signedOutAuthObject, + TokenType, + unauthenticatedMachineObject, +} from '@clerk/backend/internal'; import { parsePublishableKey } from '@clerk/shared/keys'; -import type { PendingSessionOptions } from '@clerk/types'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import type { AuthFn } from '../app-router/server/auth'; +import type { GetAuthOptions } from '../server/createGetAuth'; import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils'; import { withLogger } from '../utils/debugLogger'; import { canUseKeyless } from '../utils/feature-flags'; @@ -14,16 +32,19 @@ import { clerkClient } from './clerkClient'; import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants'; import { type ContentSecurityPolicyOptions, createContentSecurityPolicyHeaders } from './content-security-policy'; import { errorThrower } from './errorThrower'; +import { getHeader } from './headers-utils'; import { getKeylessCookieValue } from './keyless'; import { clerkMiddlewareRequestDataStorage, clerkMiddlewareRequestDataStore } from './middleware-storage'; import { isNextjsNotFoundError, isNextjsRedirectError, + isNextjsUnauthorizedError, isRedirectToSignInError, isRedirectToSignUpError, nextjsRedirectError, redirectToSignInError, redirectToSignUpError, + unauthorized, } from './nextErrors'; import type { AuthProtect } from './protect'; import { createProtect } from './protect'; @@ -36,16 +57,17 @@ import { setRequestHeadersOnNextResponse, } from './utils'; -export type ClerkMiddlewareAuthObject = AuthObject & { +export type ClerkMiddlewareSessionAuthObject = (SignedInAuthObject | SignedOutAuthObject) & { redirectToSignIn: RedirectFun; redirectToSignUp: RedirectFun; }; -export interface ClerkMiddlewareAuth { - (opts?: PendingSessionOptions): Promise; +/** + * @deprecated Use `ClerkMiddlewareSessionAuthObject` instead. + */ +export type ClerkMiddlewareAuthObject = ClerkMiddlewareSessionAuthObject; - protect: AuthProtect; -} +export type ClerkMiddlewareAuth = AuthFn; type ClerkMiddlewareHandler = ( auth: ClerkMiddlewareAuth, @@ -53,11 +75,13 @@ type ClerkMiddlewareHandler = ( event: NextMiddlewareEvtParam, ) => NextMiddlewareReturn; +type AuthenticateAnyRequestOptions = Omit; + /** * The `clerkMiddleware()` function accepts an optional object. The following options are available. * @interface */ -export interface ClerkMiddlewareOptions extends AuthenticateRequestOptions { +export interface ClerkMiddlewareOptions extends AuthenticateAnyRequestOptions { /** * If true, additional debug information will be logged to the console. */ @@ -183,14 +207,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest); const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); - const authHandler = (opts?: PendingSessionOptions) => { - const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(requestState.toAuth(opts), { - redirectToSignIn, - redirectToSignUp, - }); - - return Promise.resolve(authObjWithMethods); - }; + const authHandler = createMiddlewareAuthHandler(requestState, redirectToSignIn, redirectToSignUp); authHandler.protect = protect; let handlerResult: Response = NextResponse.next(); @@ -264,11 +281,14 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const resolvedParams = typeof params === 'function' ? await params(request) : params; const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value); + const isMissingPublishableKey = !(resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey); + const authHeader = getHeader(request, constants.Headers.Authorization)?.replace('Bearer ', '') ?? ''; + /** * In keyless mode, if the publishable key is missing, let the request through, to render `` that will resume the flow gracefully. */ - if (isMissingPublishableKey) { + if (isMissingPublishableKey && !isMachineToken(authHeader)) { const res = NextResponse.next(); setRequestHeadersOnNextResponse(res, request, { [constants.Headers.AuthStatus]: 'signed-out', @@ -335,12 +355,16 @@ export const createAuthenticateRequestOptions = ( return { ...options, ...handleMultiDomainAndProxy(clerkRequest, options), + // TODO: Leaving the acceptsToken as 'any' opens up the possibility of + // an economic attack. We should revisit this and only verify a token + // when auth() or auth.protect() is invoked. + acceptsToken: 'any', }; }; const createMiddlewareRedirectToSignIn = ( clerkRequest: ClerkRequest, -): ClerkMiddlewareAuthObject['redirectToSignIn'] => { +): ClerkMiddlewareSessionAuthObject['redirectToSignIn'] => { return (opts = {}) => { const url = clerkRequest.clerkUrl.toString(); redirectToSignInError(url, opts.returnBackUrl); @@ -349,7 +373,7 @@ const createMiddlewareRedirectToSignIn = ( const createMiddlewareRedirectToSignUp = ( clerkRequest: ClerkRequest, -): ClerkMiddlewareAuthObject['redirectToSignUp'] => { +): ClerkMiddlewareSessionAuthObject['redirectToSignUp'] => { return (opts = {}) => { const url = clerkRequest.clerkUrl.toString(); redirectToSignUpError(url, opts.returnBackUrl); @@ -369,10 +393,64 @@ const createMiddlewareProtect = ( redirectUrl: url, }); - return createProtect({ request: clerkRequest, redirect, notFound, authObject, redirectToSignIn })(params, options); + return createProtect({ + request: clerkRequest, + redirect, + notFound, + unauthorized, + authObject, + redirectToSignIn, + })(params, options); }) as unknown as Promise; }; +/** + * Modifies the auth object based on the token type. + * - For session tokens: adds redirect functions to the auth object + * - For machine tokens: validates token type and returns appropriate auth object + */ +const createMiddlewareAuthHandler = ( + requestState: RequestState, + redirectToSignIn: RedirectFun, + redirectToSignUp: RedirectFun, +): ClerkMiddlewareAuth => { + const authHandler = async (options?: GetAuthOptions) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const authObject = requestState.toAuth(options)!; + + const authObjWithMethods = Object.assign( + authObject, + authObject.tokenType === TokenType.SessionToken + ? { + redirectToSignIn, + redirectToSignUp, + } + : {}, + ); + + const acceptsToken = options?.acceptsToken ?? TokenType.SessionToken; + + if (acceptsToken === 'any') { + return authObjWithMethods; + } + + if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { + if (authObject.tokenType === TokenType.SessionToken) { + return { + ...signedOutAuthObject(), + redirectToSignIn, + redirectToSignUp, + }; + } + return unauthenticatedMachineObject(authObject.tokenType); + } + + return authObjWithMethods; + }; + + return authHandler as ClerkMiddlewareAuth; +}; + // Handle errors thrown by protect() and redirectToSignIn() calls, // as we want to align the APIs between middleware, pages and route handlers // Normally, middleware requires to explicitly return a response, but we want to @@ -386,6 +464,27 @@ const handleControlFlowErrors = ( nextRequest: NextRequest, requestState: RequestState, ): Response => { + if (isNextjsUnauthorizedError(e)) { + const response = NextResponse.next({ status: 401 }); + + // RequestState.toAuth() returns a session_token type by default. + // We need to cast it to the correct type to check for OAuth tokens. + const authObject = (requestState as RequestState).toAuth(); + if (authObject && authObject.tokenType === TokenType.OAuthToken) { + // Following MCP spec, we return WWW-Authenticate header on 401 responses + // to enable OAuth 2.0 authorization server discovery (RFC9728). + // See https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location + const publishableKey = parsePublishableKey(requestState.publishableKey); + return setHeader( + response, + 'WWW-Authenticate', + `Bearer resource_metadata="https://${publishableKey?.frontendApi}/.well-known/oauth-protected-resource"`, + ); + } + + return response; + } + if (isNextjsNotFoundError(e)) { // Rewrite to a bogus URL to force not found error return setHeader( diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts index cf74811c02e..fe605829f80 100644 --- a/packages/nextjs/src/server/createGetAuth.ts +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -1,16 +1,24 @@ import type { AuthObject } from '@clerk/backend'; -import { constants } from '@clerk/backend/internal'; +import { constants, type SignedInAuthObject, type SignedOutAuthObject } from '@clerk/backend/internal'; import { isTruthy } from '@clerk/shared/underscore'; import type { PendingSessionOptions } from '@clerk/types'; import { withLogger } from '../utils/debugLogger'; import { isNextWithUnstableServerActions } from '../utils/sdk-versions'; -import { getAuthDataFromRequest } from './data/getAuthDataFromRequest'; +import type { GetAuthDataFromRequestOptions } from './data/getAuthDataFromRequest'; +import { + getAuthDataFromRequestAsync as getAuthDataFromRequestAsyncOriginal, + getAuthDataFromRequestSync as getAuthDataFromRequestSyncOriginal, +} from './data/getAuthDataFromRequest'; import { getAuthAuthHeaderMissing } from './errors'; import { detectClerkMiddleware, getHeader } from './headers-utils'; import type { RequestLike } from './types'; import { assertAuthStatus } from './utils'; +export type GetAuthOptions = { + acceptsToken?: GetAuthDataFromRequestOptions['acceptsToken']; +} & PendingSessionOptions; + /** * The async variant of our old `createGetAuth` allows for asynchronous code inside its callback. * Should be used with function like `auth()` that are already asynchronous. @@ -23,7 +31,7 @@ export const createAsyncGetAuth = ({ noAuthStatusMessage: string; }) => withLogger(debugLoggerName, logger => { - return async (req: RequestLike, opts?: { secretKey?: string } & PendingSessionOptions): Promise => { + return async (req: RequestLike, opts?: { secretKey?: string } & GetAuthOptions): Promise => { if (isTruthy(getHeader(req, constants.Headers.EnableDebug))) { logger.enable(); } @@ -46,7 +54,11 @@ export const createAsyncGetAuth = ({ assertAuthStatus(req, noAuthStatusMessage); } - return getAuthDataFromRequest(req, { ...opts, logger }); + const getAuthDataFromRequestAsync = (req: RequestLike, opts: GetAuthDataFromRequestOptions = {}) => { + return getAuthDataFromRequestAsyncOriginal(req, { ...opts, logger, acceptsToken: opts?.acceptsToken }); + }; + + return getAuthDataFromRequestAsync(req, { ...opts, logger, acceptsToken: opts?.acceptsToken }); }; }); @@ -63,13 +75,21 @@ export const createSyncGetAuth = ({ noAuthStatusMessage: string; }) => withLogger(debugLoggerName, logger => { - return (req: RequestLike, opts?: { secretKey?: string } & PendingSessionOptions): AuthObject => { + return ( + req: RequestLike, + opts?: { secretKey?: string } & GetAuthOptions, + ): SignedInAuthObject | SignedOutAuthObject => { if (isTruthy(getHeader(req, constants.Headers.EnableDebug))) { logger.enable(); } assertAuthStatus(req, noAuthStatusMessage); - return getAuthDataFromRequest(req, { ...opts, logger }); + + const getAuthDataFromRequestSync = (req: RequestLike, opts: GetAuthDataFromRequestOptions = {}) => { + return getAuthDataFromRequestSyncOriginal(req, { ...opts, logger, acceptsToken: opts?.acceptsToken }); + }; + + return getAuthDataFromRequestSync(req, { ...opts, logger, acceptsToken: opts?.acceptsToken }); }; }); diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index 43c15cd5c2d..bc2938036fb 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -1,5 +1,18 @@ import type { AuthObject } from '@clerk/backend'; -import { AuthStatus, constants, signedInAuthObject, signedOutAuthObject } from '@clerk/backend/internal'; +import type { AuthenticateRequestOptions, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; +import { + authenticatedMachineObject, + AuthStatus, + constants, + getMachineTokenType, + isMachineToken, + isTokenTypeAccepted, + signedInAuthObject, + signedOutAuthObject, + TokenType, + unauthenticatedMachineObject, + verifyMachineAuthToken, +} from '@clerk/backend/internal'; import { decodeJwt } from '@clerk/backend/jwt'; import type { PendingSessionOptions } from '@clerk/types'; @@ -9,22 +22,17 @@ import { getAuthKeyFromRequest, getHeader } from '../headers-utils'; import type { RequestLike } from '../types'; import { assertTokenSignature, decryptClerkRequestData } from '../utils'; -/** - * Given a request object, builds an auth object from the request data. Used in server-side environments to get access - * to auth data for a given request. - */ -export function getAuthDataFromRequest( +export type GetAuthDataFromRequestOptions = { + secretKey?: string; + logger?: LoggerNoCommit; + acceptsToken?: AuthenticateRequestOptions['acceptsToken']; +} & PendingSessionOptions; + +export const getAuthDataFromRequestSync = ( req: RequestLike, - { - treatPendingAsSignedOut = true, - ...opts - }: { secretKey?: string; logger?: LoggerNoCommit } & PendingSessionOptions = {}, -): AuthObject { - const authStatus = getAuthKeyFromRequest(req, 'AuthStatus'); - const authToken = getAuthKeyFromRequest(req, 'AuthToken'); - const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); - const authReason = getAuthKeyFromRequest(req, 'AuthReason'); - const authSignature = getAuthKeyFromRequest(req, 'AuthSignature'); + { treatPendingAsSignedOut = true, ...opts }: GetAuthDataFromRequestOptions = {}, +): SignedInAuthObject | SignedOutAuthObject => { + const { authStatus, authMessage, authReason, authToken, authSignature } = getAuthHeaders(req); opts.logger?.debug('headers', { authStatus, authMessage, authReason }); @@ -42,7 +50,11 @@ export function getAuthDataFromRequest( treatPendingAsSignedOut, }; - opts.logger?.debug('auth options', options); + // Only accept session tokens in the synchronous version. + // Machine tokens are not supported in this function. Any machine token input will result in a signed-out state. + if (!isTokenTypeAccepted(TokenType.SessionToken, opts.acceptsToken || TokenType.SessionToken)) { + return signedOutAuthObject(options); + } let authObject; if (!authStatus || authStatus !== AuthStatus.SignedIn) { @@ -63,4 +75,67 @@ export function getAuthDataFromRequest( } return authObject; -} +}; + +/** + * Note: We intentionally avoid using interface/function overloads here since these functions + * are used internally. The complex type overloads are more valuable at the public API level + * (like in auth.protect(), auth()) where users interact directly with the types. + * + * Given a request object, builds an auth object from the request data. Used in server-side environments to get access + * to auth data for a given request. + */ +export const getAuthDataFromRequestAsync = async ( + req: RequestLike, + opts: GetAuthDataFromRequestOptions = {}, +): Promise => { + const { authStatus, authMessage, authReason } = getAuthHeaders(req); + + opts.logger?.debug('headers', { authStatus, authMessage, authReason }); + + const bearerToken = getHeader(req, constants.Headers.Authorization)?.replace('Bearer ', ''); + const acceptsToken = opts.acceptsToken || TokenType.SessionToken; + + if (bearerToken && isMachineToken(bearerToken)) { + const tokenType = getMachineTokenType(bearerToken); + + const options = { + secretKey: opts?.secretKey || SECRET_KEY, + publishableKey: PUBLISHABLE_KEY, + apiUrl: API_URL, + authStatus, + authMessage, + authReason, + }; + + if (!isTokenTypeAccepted(tokenType, acceptsToken)) { + return unauthenticatedMachineObject(tokenType, options); + } + + // TODO(Rob): Cache the result of verifyMachineAuthToken + const { data, errors } = await verifyMachineAuthToken(bearerToken, options); + if (errors) { + return unauthenticatedMachineObject(tokenType, options); + } + + return authenticatedMachineObject(tokenType, bearerToken, data); + } + + return getAuthDataFromRequestSync(req, opts); +}; + +const getAuthHeaders = (req: RequestLike) => { + const authStatus = getAuthKeyFromRequest(req, 'AuthStatus'); + const authToken = getAuthKeyFromRequest(req, 'AuthToken'); + const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); + const authReason = getAuthKeyFromRequest(req, 'AuthReason'); + const authSignature = getAuthKeyFromRequest(req, 'AuthSignature'); + + return { + authStatus, + authToken, + authMessage, + authReason, + authSignature, + }; +}; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 93348d9d7f5..a2bdebbf685 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -44,7 +44,12 @@ export { buildClerkProps } from './buildClerkProps'; export { auth } from '../app-router/server/auth'; export { currentUser } from '../app-router/server/currentUser'; export { clerkMiddleware } from './clerkMiddleware'; -export type { ClerkMiddlewareAuth, ClerkMiddlewareAuthObject, ClerkMiddlewareOptions } from './clerkMiddleware'; +export type { + ClerkMiddlewareAuth, + ClerkMiddlewareSessionAuthObject, + ClerkMiddlewareAuthObject, + ClerkMiddlewareOptions, +} from './clerkMiddleware'; /** * Re-export resource types from @clerk/backend diff --git a/packages/nextjs/src/server/nextErrors.ts b/packages/nextjs/src/server/nextErrors.ts index d5277a7104f..fc205f7ac89 100644 --- a/packages/nextjs/src/server/nextErrors.ts +++ b/packages/nextjs/src/server/nextErrors.ts @@ -155,6 +155,20 @@ function isRedirectToSignUpError(error: unknown): error is RedirectError<{ retur return false; } +function isNextjsUnauthorizedError(error: unknown): error is HTTPAccessFallbackError { + return whichHTTPAccessFallbackError(error) === HTTPAccessErrorStatusCodes.UNAUTHORIZED; +} + +/** + * In-house implementation of experimental `unauthorized()` + * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unauthorized.ts + */ +function unauthorized(): never { + const error = new Error(HTTP_ERROR_FALLBACK_ERROR_CODE) as HTTPAccessFallbackError; + error.digest = `${HTTP_ERROR_FALLBACK_ERROR_CODE};${HTTPAccessErrorStatusCodes.UNAUTHORIZED}`; + throw error; +} + export { isNextjsNotFoundError, isLegacyNextjsNotFoundError, @@ -164,4 +178,6 @@ export { isNextjsRedirectError, isRedirectToSignInError, isRedirectToSignUpError, + isNextjsUnauthorizedError, + unauthorized, }; diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index c53bb104a5a..8dd50fb050e 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -1,6 +1,11 @@ import type { AuthObject } from '@clerk/backend'; -import type { RedirectFun, SignedInAuthObject } from '@clerk/backend/internal'; -import { constants } from '@clerk/backend/internal'; +import type { + AuthenticatedMachineObject, + AuthenticateRequestOptions, + RedirectFun, + SignedInAuthObject, +} from '@clerk/backend/internal'; +import { constants, isTokenTypeAccepted, TokenType } from '@clerk/backend/internal'; import type { CheckAuthorizationFromSessionClaims, CheckAuthorizationParamsFromSessionClaims, @@ -11,8 +16,13 @@ import type { import { constants as nextConstants } from '../constants'; import { isNextFetcher } from './nextFetcher'; +import type { InferAuthObjectFromToken, InferAuthObjectFromTokenArray } from './types'; type AuthProtectOptions = { + /** + * The token type to check. + */ + token?: AuthenticateRequestOptions['acceptsToken']; /** * The URL to redirect the user to if they are not authorized. */ @@ -27,16 +37,45 @@ type AuthProtectOptions = { * Throws a Nextjs notFound error if user is not authenticated or authorized. */ export interface AuthProtect { + /** + * @example + * auth.protect({ permission: 'org:admin:example1' }); + * auth.protect({ role: 'admin' }); + */

( params?: CheckAuthorizationParamsFromSessionClaims

, options?: AuthProtectOptions, ): Promise; + /** + * @example + * auth.protect(has => has({ permission: 'org:admin:example1' })); + */ ( params?: (has: CheckAuthorizationFromSessionClaims) => boolean, options?: AuthProtectOptions, ): Promise; + /** + * @example + * auth.protect({ token: 'session_token' }); + */ + ( + options?: AuthProtectOptions & { token: T }, + ): Promise>; + + /** + * @example + * auth.protect({ token: ['session_token', 'machine_token'] }); + */ + ( + options?: AuthProtectOptions & { token: T }, + ): Promise>; + + /** + * @example + * auth.protect(); + */ (options?: AuthProtectOptions): Promise; } @@ -53,6 +92,10 @@ export function createProtect(opts: { * see {@link notFound} above */ redirect: (url: string) => void; + /** + * For m2m requests, throws a 401 response + */ + unauthorized: () => void; /** * protect() in middleware redirects to signInUrl if signed out * protect() in pages throws a notFound error if signed out @@ -60,17 +103,13 @@ export function createProtect(opts: { */ redirectToSignIn: RedirectFun; }): AuthProtect { - const { redirectToSignIn, authObject, redirect, notFound, request } = opts; + const { redirectToSignIn, authObject, redirect, notFound, request, unauthorized } = opts; return (async (...args: any[]) => { - const optionValuesAsParam = args[0]?.unauthenticatedUrl || args[0]?.unauthorizedUrl; - const paramsOrFunction = optionValuesAsParam - ? undefined - : (args[0] as - | CheckAuthorizationParamsWithCustomPermissions - | ((has: CheckAuthorizationWithCustomPermissions) => boolean)); + const paramsOrFunction = getAuthorizationParams(args[0]); const unauthenticatedUrl = (args[0]?.unauthenticatedUrl || args[1]?.unauthenticatedUrl) as string | undefined; const unauthorizedUrl = (args[0]?.unauthorizedUrl || args[1]?.unauthorizedUrl) as string | undefined; + const requestedToken = args[0]?.token || args[1]?.token || TokenType.SessionToken; const handleUnauthenticated = () => { if (unauthenticatedUrl) { @@ -84,12 +123,30 @@ export function createProtect(opts: { }; const handleUnauthorized = () => { + // For machine tokens, return a 401 response + if (authObject.tokenType !== TokenType.SessionToken) { + return unauthorized(); + } + if (unauthorizedUrl) { return redirect(unauthorizedUrl); } return notFound(); }; + if (!isTokenTypeAccepted(authObject.tokenType, requestedToken)) { + return handleUnauthorized(); + } + + if (authObject.tokenType !== TokenType.SessionToken) { + // For machine tokens, we only check if they're authenticated + // They don't have session status or organization permissions + if (!authObject.id) { + return handleUnauthorized(); + } + return authObject; + } + /** * Redirects the user back to the tasks URL if their session status is pending */ @@ -132,6 +189,27 @@ export function createProtect(opts: { }) as AuthProtect; } +const getAuthorizationParams = (arg: any) => { + if (!arg) { + return undefined; + } + + // Skip authorization check if the arg contains any of these options + if (arg.unauthenticatedUrl || arg.unauthorizedUrl || arg.token) { + return undefined; + } + + // Skip if it's just a token-only object + if (Object.keys(arg).length === 1 && 'token' in arg) { + return undefined; + } + + // Return the authorization params/function + return arg as + | CheckAuthorizationParamsWithCustomPermissions + | ((has: CheckAuthorizationWithCustomPermissions) => boolean); +}; + const isServerActionRequest = (req: Request) => { return ( !!req.headers.get(nextConstants.Headers.NextUrl) && diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index b1f15bc79fd..347827983ad 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -1,3 +1,5 @@ +import type { AuthObject } from '@clerk/backend'; +import type { SessionTokenType, TokenType } from '@clerk/backend/internal'; import type { IncomingMessage } from 'http'; import type { NextApiRequest } from 'next'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; @@ -11,3 +13,29 @@ export type RequestLike = NextRequest | NextApiRequest | GsspRequest; export type NextMiddlewareRequestParam = Parameters['0']; export type NextMiddlewareEvtParam = Parameters['1']; export type NextMiddlewareReturn = ReturnType; + +/** + * 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 }; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 220acd1cc67..fc87bd44074 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -106,10 +106,10 @@ export default defineNuxtModule({ addTypeTemplate( { filename: 'types/clerk.d.ts', - getContents: () => `import type { AuthObject } from '@clerk/backend'; + getContents: () => `import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; declare module 'h3' { - type AuthObjectHandler = AuthObject & { - (): AuthObject; + type AuthObjectHandler = (SignedInAuthObject | SignedOutAuthObject) & { + (): SignedInAuthObject | SignedOutAuthObject; } interface H3EventContext { diff --git a/packages/nuxt/src/runtime/server/getAuth.ts b/packages/nuxt/src/runtime/server/getAuth.ts index 339f76cac60..c62d43ce5c1 100644 --- a/packages/nuxt/src/runtime/server/getAuth.ts +++ b/packages/nuxt/src/runtime/server/getAuth.ts @@ -1,8 +1,9 @@ +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { H3Event } from 'h3'; import { moduleRegistrationRequired } from './errors'; -export function getAuth(event: H3Event) { +export function getAuth(event: H3Event): SignedInAuthObject | SignedOutAuthObject { const authObject = event.context.auth(); if (!authObject) { diff --git a/packages/nuxt/src/runtime/server/utils.ts b/packages/nuxt/src/runtime/server/utils.ts index efc87424bde..41aaf18a2ad 100644 --- a/packages/nuxt/src/runtime/server/utils.ts +++ b/packages/nuxt/src/runtime/server/utils.ts @@ -1,4 +1,4 @@ -import type { AuthObject } from '@clerk/backend'; +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import { makeAuthObjectSerializable, stripPrivateDataFromObject } from '@clerk/backend/internal'; import type { InitialState } from '@clerk/types'; import type { H3Event } from 'h3'; @@ -17,7 +17,7 @@ export function toWebRequest(event: H3Event) { }); } -export function createInitialState(auth: AuthObject) { +export function createInitialState(auth: SignedInAuthObject | SignedOutAuthObject) { const initialState = makeAuthObjectSerializable(stripPrivateDataFromObject(auth)); return initialState as unknown as InitialState; } diff --git a/packages/react-router/src/ssr/types.ts b/packages/react-router/src/ssr/types.ts index fc2df32cbae..dc159b08d0e 100644 --- a/packages/react-router/src/ssr/types.ts +++ b/packages/react-router/src/ssr/types.ts @@ -1,5 +1,5 @@ -import type { AuthObject, Organization, Session, User, VerifyTokenOptions } from '@clerk/backend'; -import type { RequestState } from '@clerk/backend/internal'; +import type { Organization, Session, User, VerifyTokenOptions } from '@clerk/backend'; +import type { RequestState, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { LegacyRedirectProps, MultiDomainAndOrProxy, @@ -38,7 +38,7 @@ export type RouteInfo = { actionData: unknown; }; -export type GetAuthReturn = Promise; +export type GetAuthReturn = Promise; export type RootAuthLoaderOptions = { /** @@ -110,7 +110,7 @@ export type LoaderFunctionArgsWithAuth = LoaderFunctionArgs['request'] & { - auth: Omit; + auth: Omit; } & (Options extends { loadSession: true } ? { session: Session | null } : object) & (Options extends { loadUser: true } ? { user: User | null } : object) & (Options extends { loadOrganization: true } ? { organization: Organization | null } : object); diff --git a/packages/remix/src/ssr/types.ts b/packages/remix/src/ssr/types.ts index a6972cd6d29..52b75f1301f 100644 --- a/packages/remix/src/ssr/types.ts +++ b/packages/remix/src/ssr/types.ts @@ -1,5 +1,5 @@ -import type { AuthObject, Organization, Session, User, VerifyTokenOptions } from '@clerk/backend'; -import type { RequestState } from '@clerk/backend/internal'; +import type { Organization, Session, User, VerifyTokenOptions } from '@clerk/backend'; +import type { RequestState, SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { LegacyRedirectProps, MultiDomainAndOrProxy, @@ -11,7 +11,7 @@ import type { } from '@clerk/types'; import type { DataFunctionArgs, LoaderFunction } from '@remix-run/server-runtime'; -export type GetAuthReturn = Promise; +export type GetAuthReturn = Promise; export type RootAuthLoaderOptions = { publishableKey?: string; @@ -69,7 +69,7 @@ export type LoaderFunctionArgsWithAuth = LoaderFunctionArgs['request'] & { - auth: Omit; + auth: Omit; } & (Options extends { loadSession: true } ? { session: Session | null } : object) & (Options extends { loadUser: true } ? { user: User | null } : object) & (Options extends { loadOrganization: true } ? { organization: Organization | null } : object); diff --git a/packages/tanstack-react-start/src/server/getAuth.ts b/packages/tanstack-react-start/src/server/getAuth.ts index b89b19d00ab..f2edff0f610 100644 --- a/packages/tanstack-react-start/src/server/getAuth.ts +++ b/packages/tanstack-react-start/src/server/getAuth.ts @@ -1,4 +1,4 @@ -import type { AuthObject } from '@clerk/backend'; +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import { stripPrivateDataFromObject } from '@clerk/backend/internal'; import { errorThrower } from '../utils'; @@ -7,7 +7,7 @@ import { authenticateRequest } from './authenticateRequest'; import { loadOptions } from './loadOptions'; import type { LoaderOptions } from './types'; -type GetAuthReturn = Promise; +type GetAuthReturn = Promise; type GetAuthOptions = Pick;