diff --git a/packages/agent-toolkit/src/lib/types.ts b/packages/agent-toolkit/src/lib/types.ts index 9cccc3f033..d505c84977 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/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 17c4894285..080de77cca 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,24 @@ describe('subpath /internal exports', () => { expect(Object.keys(internalExports).sort()).toMatchInlineSnapshot(` [ "AuthStatus", + "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 0000000000..4cf973de28 --- /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 0000000000..589c81d657 --- /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 0000000000..4c61f35d23 --- /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 2cf61f0af7..26b3f2e8d3 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 164cfb87d0..f26eea4acd 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,25 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { emailAddresses: new EmailAddressAPI(request), instance: new InstanceAPI(request), invitations: new InvitationAPI(request), + // TODO: Remove this once we add a version to bapi-proxy + 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 0000000000..7aaaa59e5a --- /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 creationReason: string | null, + readonly secondsUntilExpiration: 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.creation_reason, + data.seconds_until_expiration, + 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 45c15e4f45..8df06bf3d8 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, @@ -85,6 +88,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: @@ -97,6 +102,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: @@ -107,6 +114,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 0000000000..f1b137aa8a --- /dev/null +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -0,0 +1,35 @@ +import type { IdPOAuthAccessTokenJSON } from './JSON'; + +export class IdPOAuthAccessToken { + constructor( + readonly id: string, + readonly clientId: string, + readonly type: string, + readonly name: 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.name, + 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 20313ea84c..e36f8a49d5 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_token', JwtTemplate: 'jwt_template', OauthAccessToken: 'oauth_access_token', + IdpOAuthAccessToken: 'clerk_idp_oauth_access_token', OAuthApplication: 'oauth_application', Organization: 'organization', OrganizationDomain: 'organization_domain', @@ -672,6 +675,55 @@ 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; + creation_reason: string | null; + seconds_until_expiration: number | null; + created_at: number; + updated_at: number; +} + +export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { + object: typeof ObjectType.IdpOAuthAccessToken; + client_id: string; + type: string; + name: 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 0000000000..32cccdf365 --- /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 e21aa4ade2..585aa1e1bc 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 90cfdc343a..2e4d6890d7 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 0000000000..4bfcacc99b --- /dev/null +++ b/packages/backend/src/fixtures/machine.ts @@ -0,0 +1,69 @@ +export const mockTokens = { + api_key: 'api_key_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9=', + oauth_token: 'oauth_access_8XOIucKvqHVr5tYP123456789abcdefghij', + machine_token: 'm2m_8XOIucKvqHVr5tYP123456789abcdefghij', +} as const; + +export const mockVerificationResults = { + api_key: { + id: 'api_key_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: 'oauth_access_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: 'm2m_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 c9b7daf9ff..18670a2f03 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -7,13 +7,31 @@ export { createAuthenticateRequest } from './tokens/factory'; 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 type { AuthenticateRequestOptions, OrganizationSyncOptions, TokenType } from './tokens/types'; + +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 +39,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 697df4abfc..ecd7689e15 100644 --- a/packages/backend/src/jwt/types.ts +++ b/packages/backend/src/jwt/types.ts @@ -1,3 +1,5 @@ +import type { MachineTokenType } from '../tokens/types'; + 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 0cc554de98..f090780cc1 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', () => { @@ -10,7 +17,7 @@ describe('makeAuthObjectSerializable', () => { const serializableAuthObject = makeAuthObjectSerializable(authObject); for (const key in serializableAuthObject) { - expect(typeof serializableAuthObject[key]).not.toBe('function'); + expect(typeof serializableAuthObject[key as keyof typeof serializableAuthObject]).not.toBe('function'); } }); }); @@ -174,3 +181,105 @@ 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.name).toBe('GitHub OAuth'); + 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 87b50c567b..b12afcf8dc 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('api_key_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__/machine.test.ts b/packages/backend/src/tokens/__tests__/machine.test.ts new file mode 100644 index 0000000000..dd84e40d3b --- /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 c3409decee..f69e6ba03d 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, test, vi } from 'vitest'; -import { TokenVerificationErrorReason } from '../../errors'; +import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors'; import { mockExpiredJwt, mockInvalidSignatureJwt, @@ -10,6 +10,7 @@ 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'; @@ -20,7 +21,7 @@ import { type OrganizationSyncTarget, RefreshTokenErrorReason, } from '../request'; -import type { AuthenticateRequestOptions, OrganizationSyncOptions } from '../types'; +import type { AuthenticateRequestOptions, MachineTokenType, OrganizationSyncOptions } from '../types'; const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; @@ -31,6 +32,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' { @@ -218,6 +223,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 = { @@ -231,7 +303,7 @@ const mockRequest = (headers = {}, requestUrl = 'http://clerk.com/path') => { }; /* An otherwise bare state on a request. */ -const mockOptions = (options?) => { +const mockOptions = (options?: Partial) => { return { secretKey: 'deadbeef', apiUrl: 'https://api.clerk.test', @@ -249,11 +321,11 @@ const mockOptions = (options?) => { } satisfies AuthenticateRequestOptions; }; -const mockRequestWithHeaderAuth = (headers?, requestUrl?) => { +const mockRequestWithHeaderAuth = (headers?: Record, requestUrl?: string) => { return mockRequest({ authorization: `Bearer ${mockJwt}`, ...headers }, requestUrl); }; -const mockRequestWithCookies = (headers?, cookies = {}, requestUrl?) => { +const mockRequestWithCookies = (headers?: Record, cookies = {}, requestUrl?: string) => { const cookieStr = Object.entries(cookies) .map(([k, v]) => `${k}=${v}`) .join(';'); @@ -1132,4 +1204,200 @@ describe('tokens.authenticateRequest(options)', () => { expect(refreshSession).toHaveBeenCalled(); }); }); + + 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 895f948b06..72b4342e97 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,234 @@ 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 = 'api_key_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('api_key_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 = 'm2m_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('m2m_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 = 'oauth_access_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('oauth_access_2VTWUzvGC5UhdJCNx6xG1D98edc'); + expect(data.name).toBe('GitHub OAuth'); + 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 = 'api_key_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 = 'api_key_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 = 'm2m_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 = 'm2m_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 = 'oauth_access_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 = 'oauth_access_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 df0837eb19..7142a52cd1 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -8,13 +8,16 @@ 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 { MachineAuthType, MachineTokenType } from './types'; type AuthObjectDebugData = Record; type AuthObjectDebug = () => AuthObjectDebugData; +type Claims = Record; + /** * @internal */ @@ -26,6 +29,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & { * @internal */ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { + tokenType: 'session_token'; getToken: ServerGetToken; has: CheckAuthorizationFromSessionClaims; debug: AuthObjectDebug; @@ -39,6 +43,7 @@ export type SignedOutAuthObject = { sessionId: null; sessionStatus: null; actor: null; + tokenType: 'session_token'; userId: null; orgId: null; orgRole: null; @@ -55,10 +60,56 @@ 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: { claims: TAuthenticated extends true ? Claims | null : null }; + machine_token: { claims: TAuthenticated extends true ? Claims | null : null }; + oauth_token: object; +}; + +/** + * @internal + */ +export type AuthenticatedMachineObject = { + id: string; + name: 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; + name: 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 () => { @@ -86,6 +137,7 @@ export function signedInAuthObject( fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt, }); return { + tokenType: 'session_token', actor, sessionClaims, sessionId, @@ -115,6 +167,7 @@ export function signedInAuthObject( */ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutAuthObject { return { + tokenType: 'session_token', sessionClaims: null, sessionId: null, sessionStatus: null, @@ -131,6 +184,102 @@ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutA }; } +/** + * @internal + */ +export function authenticatedMachineObject( + tokenType: T, + token: string, + verificationResult: MachineAuthType, + debugData?: AuthObjectDebugData, +): AuthenticatedMachineObject { + const baseObject = { + id: verificationResult.id, + name: verificationResult.name, + 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 'api_key': { + const result = verificationResult as APIKey; + return { + ...baseObject, + tokenType, + claims: result.claims, + scopes: result.scopes, + }; + } + case 'machine_token': { + const result = verificationResult as MachineToken; + return { + ...baseObject, + tokenType, + claims: result.claims, + scopes: result.scopes, + }; + } + case 'oauth_token': { + 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, + name: null, + subject: null, + scopes: null, + has: () => false, + getToken: () => Promise.resolve(null), + debug: createDebug(debugData), + }; + + switch (tokenType) { + case 'api_key': { + return { + ...baseObject, + tokenType, + claims: null, + }; + } + case 'machine_token': { + return { + ...baseObject, + tokenType, + claims: null, + }; + } + case 'oauth_token': { + 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 0d73f3e253..059f061b12 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -3,8 +3,19 @@ import type { JwtPayload } 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 { MachineAuthType, MachineTokenType, TokenType } from './types'; export const AuthStatus = { SignedIn: 'signed-in', @@ -14,7 +25,13 @@ export const AuthStatus = { export type AuthStatus = (typeof AuthStatus)[keyof typeof AuthStatus]; -export type SignedInState = { +type ToAuth = T extends 'session_token' + ? () => Authenticated extends true ? SignedInAuthObject : SignedOutAuthObject + : () => Authenticated extends true + ? AuthenticatedMachineObject> + : UnauthenticatedMachineObject>; + +export type AuthenticatedState = { status: typeof AuthStatus.SignedIn; reason: null; message: null; @@ -26,16 +43,21 @@ export type SignedInState = { signUpUrl: string; afterSignInUrl: string; afterSignUpUrl: string; + /** + * @deprecated Use `isAuthenticated` instead. + */ isSignedIn: true; - toAuth: () => SignedInAuthObject; + 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 +66,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: 'session_token'; status: typeof AuthStatus.Handshake; headers: Headers; toAuth: () => null; }; +/** + * @deprecated Use AuthenticatedState instead + */ +export type SignedInState = AuthenticatedState<'session_token'>; + +/** + * @deprecated Use UnauthenticatedState instead + */ +export type SignedOutState = UnauthenticatedState<'session_token'>; + export const AuthErrorReason = { ClientUATWithoutSessionToken: 'client-uat-but-no-session-token', DevBrowserMissing: 'dev-browser-missing', @@ -70,6 +108,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 +116,35 @@ 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 'session_token' ? HandshakeState : never); + +type BaseSignedInParams = { + authenticateContext: AuthenticateContext; + headers?: Headers; + token: string; + tokenType: TokenType; +}; + +type SignedInParams = + | (BaseSignedInParams & { tokenType: 'session_token'; sessionClaims: JwtPayload }) + | (BaseSignedInParams & { tokenType: MachineTokenType; machineData: MachineAuthType }); + +export function signedIn(params: SignedInParams & { tokenType: T }): AuthenticatedState { + const { authenticateContext, headers = new Headers(), token } = params; + + const toAuth = (() => { + if (params.tokenType === 'session_token') { + const { sessionClaims } = params as { sessionClaims: JwtPayload }; + return signedInAuthObject(authenticateContext, token, sessionClaims); + } + + 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,18 +158,30 @@ export function signedIn( afterSignInUrl: authenticateContext.afterSignInUrl || '', afterSignUpUrl: authenticateContext.afterSignUpUrl || '', isSignedIn: true, - toAuth: () => 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 === 'session_token') { + return signedOutAuthObject({ ...authenticateContext, status: AuthStatus.SignedOut, reason, message }); + } + + return unauthenticatedMachineObject(tokenType, { reason, message, headers }); + }) as ToAuth; + return withDebugHeaders({ status: AuthStatus.SignedOut, reason, @@ -124,8 +195,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, }); } @@ -149,13 +222,17 @@ export function handshake( afterSignInUrl: authenticateContext.afterSignInUrl || '', afterSignUpUrl: authenticateContext.afterSignUpUrl || '', isSignedIn: false, - headers, + isAuthenticated: false, + tokenType: 'session_token', 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 3f945c0a7c..5be1a592d4 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; + sessionOrMachineTokenInHeader: string | undefined; origin: string | undefined; host: string | undefined; forwardedHost: string | undefined; @@ -48,7 +48,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.sessionOrMachineTokenInHeader; } public constructor( @@ -171,7 +171,7 @@ class AuthenticateContext implements AuthenticateContext { } private initHeaderValues() { - this.sessionTokenInHeader = this.parseAuthorizationHeader(this.getHeader(constants.Headers.Authorization)); + this.sessionOrMachineTokenInHeader = 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 c6369d2baf..d45a7c20c1 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, { @@ -58,7 +59,7 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio apiVersion, apiClient, }); - }; + }) satisfies AuthenticateRequest; return { authenticateRequest, diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts new file mode 100644 index 0000000000..498cf30831 --- /dev/null +++ b/packages/backend/src/tokens/machine.ts @@ -0,0 +1,42 @@ +import type { AuthenticateRequestOptions, MachineTokenType, TokenType } from '../tokens/types'; + +export const M2M_TOKEN_PREFIX = 'm2m_'; +export const OAUTH_TOKEN_PREFIX = 'oauth_access_'; +export const API_KEY_PREFIX = 'api_key_'; + +const MACHINE_TOKEN_PREFIXES = [M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX, API_KEY_PREFIX] as const; + +export function isMachineToken(token: string): boolean { + return MACHINE_TOKEN_PREFIXES.some(prefix => token.startsWith(prefix)); +} + +export function getMachineTokenType(token: string): MachineTokenType { + if (token.startsWith(M2M_TOKEN_PREFIX)) { + return 'machine_token'; + } + + if (token.startsWith(OAUTH_TOKEN_PREFIX)) { + return 'oauth_token'; + } + + if (token.startsWith(API_KEY_PREFIX)) { + return 'api_key'; + } + + throw new Error('Unknown machine token type'); +} + +/** + * Check if a token type is accepted given a requested token type or list of token types. + */ +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 9d71402961..17c9c185d4 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -4,20 +4,21 @@ 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 { verifyHandshakeToken } from './handshake'; -import type { AuthenticateRequestOptions, OrganizationSyncOptions } from './types'; -import { verifyToken } from './verify'; +import { getMachineTokenType, isMachineToken, isTokenTypeAccepted } from './machine'; +import type { AuthenticateRequestOptions, MachineTokenType, OrganizationSyncOptions, TokenType } from './types'; +import { verifyMachineAuthToken, verifyToken } from './verify'; export const RefreshTokenErrorReason = { NonEligibleNoCookie: 'non-eligible-no-refresh-cookie', @@ -90,13 +91,64 @@ function isRequestEligibleForRefresh( ); } -export async function authenticateRequest( +function maybeHandleTokenTypeMismatch( + 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 ?? 'session_token'; + if (authenticateContext.isSatellite) { assertSignInUrlExists(authenticateContext.signInUrl, authenticateContext.secretKey); if (authenticateContext.signInUrl && authenticateContext.origin) { @@ -177,12 +229,23 @@ export async function authenticateRequest( } if (sessionToken === '') { - return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); + return signedOut({ + tokenType: 'session_token', + authenticateContext, + reason: AuthErrorReason.SessionTokenMissing, + headers, + }); } const { data, errors: [error] = [] } = await verifyToken(sessionToken, authenticateContext); if (data) { - return signedIn(authenticateContext, data, headers, sessionToken); + return signedIn({ + tokenType: 'session_token', + authenticateContext, + sessionClaims: data, + headers, + token: sessionToken, + }); } if ( @@ -209,7 +272,13 @@ ${error.getFullMessage()}`, clockSkewInMs: 86_400_000, }); if (retryResult) { - return signedIn(authenticateContext, retryResult, headers, sessionToken); + return signedIn({ + tokenType: 'session_token', + authenticateContext, + sessionClaims: retryResult, + headers, + token: sessionToken, + }); } throw new Error(retryError?.message || 'Clerk: Handshake retry failed.'); @@ -372,13 +441,23 @@ ${error.getFullMessage()}`, 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: 'session_token', + authenticateContext, + reason, + message, + }); } return handshake(authenticateContext, reason, message, handshakeHeaders); } - return signedOut(authenticateContext, reason, message); + return signedOut({ + tokenType: 'session_token', + authenticateContext, + reason, + message, + }); } /** @@ -443,17 +522,23 @@ ${error.getFullMessage()}`, } async function authenticateRequestWithTokenInHeader() { - const { sessionTokenInHeader } = authenticateContext; + const { sessionOrMachineTokenInHeader } = authenticateContext; try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { data, errors } = await verifyToken(sessionTokenInHeader!, authenticateContext); + const { data, errors } = await verifyToken(sessionOrMachineTokenInHeader!, 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: 'session_token', + authenticateContext, + sessionClaims: data, + headers: new Headers(), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + token: sessionOrMachineTokenInHeader!, + }); } catch (err) { return handleError(err, 'header'); } @@ -585,7 +670,11 @@ ${error.getFullMessage()}`, } if (!hasActiveClient && !hasSessionToken) { - return signedOut(authenticateContext, AuthErrorReason.SessionTokenAndUATMissing, ''); + return signedOut({ + tokenType: 'api_key', + authenticateContext, + reason: AuthErrorReason.SessionTokenAndUATMissing, + }); } // This can eagerly run handshake since client_uat is SameSite=Strict in dev @@ -614,13 +703,15 @@ ${error.getFullMessage()}`, if (errors) { throw errors[0]; } - const signedInRequestState = signedIn( + + const signedInRequestState = signedIn({ + tokenType: 'session_token', authenticateContext, - data, - undefined, + sessionClaims: data, + headers: new Headers(), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - authenticateContext.sessionTokenInCookie!, - ); + token: authenticateContext.sessionTokenInCookie!, + }); // Org sync if necessary const handshakeRequestState = handleMaybeOrganizationSyncHandshake( @@ -636,7 +727,12 @@ ${error.getFullMessage()}`, return handleError(err, 'cookie'); } - return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); + // Unreachable + return signedOut({ + tokenType: 'session_token', + authenticateContext, + reason: AuthErrorReason.UnexpectedError, + }); } async function handleError( @@ -644,7 +740,11 @@ ${error.getFullMessage()}`, tokenCarrier: TokenCarrier, ): Promise { if (!(err instanceof TokenVerificationError)) { - return signedOut(authenticateContext, AuthErrorReason.UnexpectedError); + return signedOut({ + tokenType: 'session_token', + authenticateContext, + reason: AuthErrorReason.UnexpectedError, + }); } let refreshError: string | null; @@ -652,7 +752,13 @@ ${error.getFullMessage()}`, 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: 'session_token', + 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. @@ -688,22 +794,144 @@ ${error.getFullMessage()}`, ); } - return signedOut(authenticateContext, err.reason, err.getFullMessage()); + return signedOut({ + tokenType: 'session_token', + authenticateContext, + reason: err.reason, + message: err.getFullMessage(), + }); + } + + 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 { sessionOrMachineTokenInHeader } = authenticateContext; + // Use session token error handling if no token in header (default behavior) + if (!sessionOrMachineTokenInHeader) { + return handleError(new Error('Missing token in header'), 'header'); + } + + // Handle case where tokenType is any and the token is not a machine token + if (!isMachineToken(sessionOrMachineTokenInHeader)) { + return signedOut({ + tokenType: acceptsToken as MachineTokenType, + authenticateContext, + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + } + + const parsedTokenType = getMachineTokenType(sessionOrMachineTokenInHeader); + const mismatchState = maybeHandleTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); + if (mismatchState) { + return mismatchState; + } + + const { data, tokenType, errors } = await verifyMachineAuthToken( + sessionOrMachineTokenInHeader, + authenticateContext, + ); + if (errors) { + return handleMachineError(tokenType, errors[0]); + } + return signedIn({ + tokenType, + authenticateContext, + machineData: data, + token: sessionOrMachineTokenInHeader, + }); + } + + async function authenticateAnyRequestWithTokenInHeader() { + const { sessionOrMachineTokenInHeader } = authenticateContext; + // Use session token error handling if no token in header (default behavior) + if (!sessionOrMachineTokenInHeader) { + return handleError(new Error('Missing token in header'), 'header'); + } + + // Handle as a machine token + if (isMachineToken(sessionOrMachineTokenInHeader)) { + const parsedTokenType = getMachineTokenType(sessionOrMachineTokenInHeader); + const mismatchState = maybeHandleTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); + if (mismatchState) { + return mismatchState; + } + + const { data, tokenType, errors } = await verifyMachineAuthToken( + sessionOrMachineTokenInHeader, + authenticateContext, + ); + if (errors) { + return handleMachineError(tokenType, errors[0]); + } + + return signedIn({ + tokenType, + authenticateContext, + machineData: data, + token: sessionOrMachineTokenInHeader, + }); + } + + // Handle as a regular session token + const { data, errors } = await verifyToken(sessionOrMachineTokenInHeader, authenticateContext); + if (errors) { + return handleError(errors[0], 'header'); + } + + return signedIn({ + tokenType: 'session_token', + authenticateContext, + sessionClaims: data, + token: sessionOrMachineTokenInHeader, + }); } - if (authenticateContext.sessionTokenInHeader) { - return authenticateRequestWithTokenInHeader(); + if (authenticateContext.sessionOrMachineTokenInHeader) { + if (acceptsToken === 'any') { + return authenticateAnyRequestWithTokenInHeader(); + } + + if (acceptsToken === 'session_token') { + return authenticateRequestWithTokenInHeader(); + } + + return authenticateMachineRequestWithTokenInHeader(); + } + + // Machine requests cannot have the token in the cookie, it must be in header. + if (acceptsToken === 'oauth_token' || acceptsToken === 'api_key' || acceptsToken === 'machine_token') { + 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 }; }; type OrganizationSyncTargetMatchers = { diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index f96417c818..d0bd92568a 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -1,4 +1,4 @@ -import type { ApiClient } from '../api'; +import type { ApiClient, APIKey, IdPOAuthAccessToken, MachineToken } from '../api'; import type { VerifyTokenOptions } from './verify'; export type AuthenticateRequestOptions = { @@ -47,6 +47,11 @@ export type AuthenticateRequestOptions = { * @internal */ apiClient?: ApiClient; + /** + * The type of token to accept. + * @default 'session_token' + */ + acceptsToken?: TokenType | TokenType[] | 'any'; } & VerifyTokenOptions; /** @@ -116,3 +121,9 @@ export type OrganizationSyncOptions = { * ``` */ type Pattern = string; + +export type TokenType = 'session_token' | 'oauth_token' | 'api_key' | 'machine_token'; + +export type MachineTokenType = Exclude; + +export type MachineAuthType = MachineToken | APIKey | IdPOAuthAccessToken; diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 3a2b2d7d49..c8bb068acc 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -1,11 +1,22 @@ +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 { MachineTokenType } from '../tokens/types'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; +import { API_KEY_PREFIX, M2M_TOKEN_PREFIX, OAUTH_TOKEN_PREFIX } from './machine'; export type VerifyTokenOptions = Omit & Omit & { @@ -52,3 +63,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: 'machine_token', errors: undefined }; + } catch (err: any) { + return handleClerkAPIError('machine_token', 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: 'oauth_token', errors: undefined }; + } catch (err: any) { + return handleClerkAPIError('oauth_token', 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: 'api_key', errors: undefined }; + } catch (err: any) { + return handleClerkAPIError('api_key', 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 4e90ff94cc..925cb39e4d 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/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 019b7e23fd..f203200ff3 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -110,7 +110,8 @@ export const auth: AuthFn = async () => { publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY, signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL, signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL, - sessionStatus: authObject.sessionStatus, + // TODO: Handle machine auth object + sessionStatus: authObject.tokenType === 'session_token' ? authObject.sessionStatus : null, }), returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(), ] as const; diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index c53bb104a5..4162a0aeb5 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -90,6 +90,10 @@ export function createProtect(opts: { return notFound(); }; + if (authObject.tokenType !== 'session_token') { + throw new Error('TODO: Handle machine auth object'); + } + /** * Redirects the user back to the tasks URL if their session status is pending */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bcc0c0590..3e08c95a1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2979,7 +2979,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.24': resolution: {integrity: sha512-lhdenxBC8/x/vL39j79eXE09mOaqNNLmiSDdY/PblnI+UNzGgsQ48hBTYa/MQhd0ioXXVKurZL2941dLKwcxJw==}