diff --git a/packages/agent-toolkit/src/lib/types.ts b/packages/agent-toolkit/src/lib/types.ts index 9cccc3f033a..d505c849771 100644 --- a/packages/agent-toolkit/src/lib/types.ts +++ b/packages/agent-toolkit/src/lib/types.ts @@ -1,4 +1,5 @@ -import type { AuthObject, ClerkClient } from '@clerk/backend'; +import type { ClerkClient } from '@clerk/backend'; +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend/internal'; import type { ClerkTool } from './clerk-tool'; @@ -12,7 +13,7 @@ export type ToolkitParams = { * @default {} */ authContext?: Pick< - AuthObject, + SignedInAuthObject | SignedOutAuthObject, 'userId' | 'sessionId' | 'sessionClaims' | 'orgId' | 'orgRole' | 'orgSlug' | 'orgPermissions' | 'actor' >; /** diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 17c48942856..9a3915b1427 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", diff --git a/packages/backend/src/api/endpoints/APIKeysApi.ts b/packages/backend/src/api/endpoints/APIKeysApi.ts new file mode 100644 index 00000000000..4cf973de28e --- /dev/null +++ b/packages/backend/src/api/endpoints/APIKeysApi.ts @@ -0,0 +1,15 @@ +import { joinPaths } from '../../util/path'; +import type { APIKey } from '../resources/APIKey'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/api_keys'; + +export class APIKeysAPI extends AbstractAPI { + async verifySecret(secret: string) { + return this.request({ + method: 'POST', + path: joinPaths(basePath, 'verify'), + bodyParams: { secret }, + }); + } +} diff --git a/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts new file mode 100644 index 00000000000..589c81d6574 --- /dev/null +++ b/packages/backend/src/api/endpoints/IdPOAuthAccessTokenApi.ts @@ -0,0 +1,15 @@ +import { joinPaths } from '../../util/path'; +import type { IdPOAuthAccessToken } from '../resources'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/oauth_applications/access_tokens'; + +export class IdPOAuthAccessTokenApi extends AbstractAPI { + async verifySecret(secret: string) { + return this.request({ + method: 'POST', + path: joinPaths(basePath, 'verify'), + bodyParams: { secret }, + }); + } +} diff --git a/packages/backend/src/api/endpoints/MachineTokensApi.ts b/packages/backend/src/api/endpoints/MachineTokensApi.ts new file mode 100644 index 00000000000..4c61f35d235 --- /dev/null +++ b/packages/backend/src/api/endpoints/MachineTokensApi.ts @@ -0,0 +1,15 @@ +import { joinPaths } from '../../util/path'; +import type { MachineToken } from '../resources/MachineToken'; +import { AbstractAPI } from './AbstractApi'; + +const basePath = '/m2m_tokens'; + +export class MachineTokensApi extends AbstractAPI { + async verifySecret(secret: string) { + return this.request({ + method: 'POST', + path: joinPaths(basePath, 'verify'), + bodyParams: { secret }, + }); + } +} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index 2cf61f0af75..26b3f2e8d3f 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -2,13 +2,16 @@ export * from './ActorTokenApi'; export * from './AccountlessApplicationsAPI'; export * from './AbstractApi'; export * from './AllowlistIdentifierApi'; +export * from './APIKeysApi'; export * from './BetaFeaturesApi'; export * from './BlocklistIdentifierApi'; export * from './ClientApi'; export * from './DomainApi'; export * from './EmailAddressApi'; +export * from './IdPOAuthAccessTokenApi'; export * from './InstanceApi'; export * from './InvitationApi'; +export * from './MachineTokensApi'; export * from './JwksApi'; export * from './JwtTemplatesApi'; export * from './OrganizationApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 164cfb87d04..baea10f1c67 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,9 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { emailAddresses: new EmailAddressAPI(request), instance: new InstanceAPI(request), invitations: new InvitationAPI(request), + machineTokens: new MachineTokensApi(request), + idPOAuthAccessToken: new IdPOAuthAccessTokenApi(request), + apiKeys: new APIKeysAPI(request), jwks: new JwksAPI(request), jwtTemplates: new JwtTemplatesApi(request), oauthApplications: new OAuthApplicationsApi(request), diff --git a/packages/backend/src/api/resources/APIKey.ts b/packages/backend/src/api/resources/APIKey.ts new file mode 100644 index 00000000000..5894e1d6a92 --- /dev/null +++ b/packages/backend/src/api/resources/APIKey.ts @@ -0,0 +1,31 @@ +import type { APIKeyJSON } from './JSON'; + +export class APIKey { + constructor( + readonly id: string, + readonly type: string, + readonly name: string, + readonly subject: string, + readonly claims: Record | null, + readonly createdBy: string | null, + readonly creationReason: string | null, + readonly secondsUntilExpiration: number | null, + readonly createdAt: number, + readonly expiresAt: number | null, + ) {} + + static fromJSON(data: APIKeyJSON) { + return new APIKey( + data.id, + data.type, + data.name, + data.subject, + data.claims, + data.created_by, + data.creation_reason, + data.seconds_until_expiration, + data.created_at, + data.expires_at, + ); + } +} diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 45c15e4f45d..8df06bf3d8d 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 00000000000..5603b1ca959 --- /dev/null +++ b/packages/backend/src/api/resources/IdPOAuthAccessToken.ts @@ -0,0 +1,25 @@ +import type { IdPOAuthAccessTokenJSON } from './JSON'; + +export class IdPOAuthAccessToken { + constructor( + readonly id: string, + readonly type: string, + readonly name: string, + readonly subject: string, + readonly claims: Record | null, + readonly createdAt: number, + readonly expiresAt: number, + ) {} + + static fromJSON(data: IdPOAuthAccessTokenJSON) { + return new IdPOAuthAccessToken( + data.id, + data.type, + data.name, + data.subject, + data.claims, + data.created_at, + data.expires_at, + ); + } +} diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 20313ea84c2..3827e686855 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,43 @@ export interface SamlAccountConnectionJSON extends ClerkResourceJSON { updated_at: number; } +export interface MachineTokenJSON extends ClerkResourceJSON { + object: typeof ObjectType.MachineToken; + name: string; + subject: string; + claims: Record | null; + revoked: boolean; + 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; + claims: Record | null; + created_by: string | null; + creation_reason: string | null; + seconds_until_expiration: number | null; + created_at: number; + expires_at: number | null; +} + +export interface IdPOAuthAccessTokenJSON extends ClerkResourceJSON { + object: typeof ObjectType.IdpOAuthAccessToken; + type: string; + name: string; + subject: string; + claims: Record | null; + created_at: number; + expires_at: number; +} + export interface WebhooksSvixJSON { svix_url: string; } diff --git a/packages/backend/src/api/resources/MachineToken.ts b/packages/backend/src/api/resources/MachineToken.ts new file mode 100644 index 00000000000..b96e38ee511 --- /dev/null +++ b/packages/backend/src/api/resources/MachineToken.ts @@ -0,0 +1,33 @@ +import type { MachineTokenJSON } from './JSON'; + +export class MachineToken { + constructor( + readonly id: string, + readonly name: string, + readonly subject: string, + readonly claims: Record | null, + readonly revoked: boolean, + 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.claims, + data.revoked, + data.expired, + data.expiration, + data.created_by, + data.creation_reason, + data.created_at, + data.updated_at, + ); + } +} diff --git a/packages/backend/src/api/resources/index.ts b/packages/backend/src/api/resources/index.ts index e21aa4ade25..585aa1e1bcc 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -1,6 +1,7 @@ export * from './ActorToken'; export * from './AccountlessApplication'; export * from './AllowlistIdentifier'; +export * from './APIKey'; export * from './BlocklistIdentifier'; export * from './Client'; export * from './CnameTarget'; @@ -23,11 +24,13 @@ export type { SignUpStatus } from '@clerk/types'; export * from './ExternalAccount'; export * from './IdentificationLink'; +export * from './IdPOAuthAccessToken'; export * from './Instance'; export * from './InstanceRestrictions'; export * from './InstanceSettings'; export * from './Invitation'; export * from './JSON'; +export * from './MachineToken'; export * from './JwtTemplate'; export * from './OauthAccessToken'; export * from './OAuthApplication'; diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 90cfdc343a3..2e4d6890d75 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -69,3 +69,30 @@ export class TokenVerificationError extends Error { } export class SignJWTError extends Error {} + +export const MachineTokenVerificationErrorCode = { + TokenInvalid: 'token-invalid', + InvalidSecretKey: 'secret-key-invalid', + UnexpectedError: 'unexpected-error', +} as const; + +export type MachineTokenVerificationErrorCode = + (typeof MachineTokenVerificationErrorCode)[keyof typeof MachineTokenVerificationErrorCode]; + +export class MachineTokenVerificationError extends Error { + code: MachineTokenVerificationErrorCode; + long_message?: string; + status: number; + + constructor({ message, code, status }: { message: string; code: MachineTokenVerificationErrorCode; status: number }) { + super(message); + Object.setPrototypeOf(this, MachineTokenVerificationError.prototype); + + this.code = code; + this.status = status; + } + + public getFullMessage() { + return `${this.message} (code=${this.code}, status=${this.status})`; + } +} diff --git a/packages/backend/src/fixtures/machine.ts b/packages/backend/src/fixtures/machine.ts new file mode 100644 index 00000000000..3d9faf91164 --- /dev/null +++ b/packages/backend/src/fixtures/machine.ts @@ -0,0 +1,47 @@ +export const mockApiKey = 'api_key_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9='; +export const mockOauthToken = 'oauth_8XOIucKvqHVr5tYP123456789abcdefghij'; +export const mockMachineToken = 'm2m_8XOIucKvqHVr5tYP123456789abcdefghij'; + +export const mockMachineAuthResponses = { + api_key: { + endpoint: 'https://api.clerk.test/v1/api_keys/verify', + successResponse: { + object: 'api_key', + id: 'api_key_ey966f1b1xf93586b2debdcadb0b3bd1', + type: 'api_key', + name: 'my-api-key', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + }, + errorMessage: 'API key not found', + }, + oauth_token: { + endpoint: 'https://api.clerk.test/v1/oauth_applications/access_tokens/verify', + successResponse: { + object: 'oauth_token', + id: 'oauth_token_ey966f1b1xf93586b2debdcadb0b3bd1', + type: 'oauth_token', + name: 'my-oauth-token', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + }, + errorMessage: 'OAuth token not found', + }, + machine_token: { + endpoint: 'https://api.clerk.test/v1/m2m_tokens/verify', + successResponse: { + object: 'machine_token', + id: 'm2m_ey966f1b1xf93586b2debdcadb0b3bd1', + name: 'my-machine-token', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + }, + errorMessage: 'Machine token not found', + }, +}; + +export const tokenMap = { + api_key: mockApiKey, + oauth_token: mockOauthToken, + machine_token: mockMachineToken, +}; diff --git a/packages/backend/src/jwt/types.ts b/packages/backend/src/jwt/types.ts index 697df4abfcf..8fe32da2f5d 100644 --- a/packages/backend/src/jwt/types.ts +++ b/packages/backend/src/jwt/types.ts @@ -1,3 +1,5 @@ +import type { NonSessionTokenType } 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: NonSessionTokenType; + errors?: undefined; + } + | { + data?: undefined; + tokenType: NonSessionTokenType; + errors: [E]; + }; diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index 0cc554de98e..f62c2b0f21e 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 { ObjectType } from '../../api/resources/JSON'; 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,134 @@ describe('signedInAuthObject', () => { }); }); }); + +describe('authenticatedMachineObject', () => { + describe('API Key authentication', () => { + const debugData = { foo: 'bar' }; + const token = 'api_key_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9='; + const verificationResult = { + object: ObjectType.ApiKey, + id: 'api_key_ey966f1b1xf93586b2debdcadb0b3bd1', + type: 'api_key', + name: 'my-api-key', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + createdBy: null, + creationReason: 'For testing purposes', + secondsUntilExpiration: null, + createdAt: 1745185445567, + expiresAt: 1745185445567, + }; + + 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.claims).toEqual({ foo: 'bar' }); + }); + }); + + describe('OAuth Access Token authentication', () => { + const token = 'oauth_token_8XOIucKvqHVr5tYP123456789abcdefghij'; + const verificationResult = { + object: ObjectType.IdpOAuthAccessToken, + id: 'oauth_token_2VTWUzvGC5UhdJCNx6xG1D98edc', + type: 'oauth:access_token', + name: 'GitHub OAuth', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { scope: 'read write' }, + createdAt: 1744928754551, + expiresAt: 1744928754551, + }; + + it('getToken returns the token passed in', async () => { + const authObject = authenticatedMachineObject('oauth_token', token, verificationResult); + const retrievedToken = await authObject.getToken(); + expect(retrievedToken).toBe(token); + }); + + it('has() always returns false', () => { + const authObject = authenticatedMachineObject('oauth_token', token, verificationResult); + expect(authObject.has({})).toBe(false); + }); + + it('properly initializes properties', () => { + const authObject = authenticatedMachineObject('oauth_token', token, verificationResult); + expect(authObject.tokenType).toBe('oauth_token'); + expect(authObject.name).toBe('GitHub OAuth'); + expect(authObject.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(authObject.claims).toEqual({ scope: 'read write' }); + }); + }); + + describe('Machine Token authentication', () => { + const token = 'm2m_8XOIucKvqHVr5tYP123456789abcdefghij'; + const verificationResult = { + object: ObjectType.MachineToken, + id: 'm2m_ey966f1b1xf93586b2debdcadb0b3bd1', + name: 'my-machine-token', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + revoked: false, + expired: false, + expiration: 1745185445567, + createdBy: null, + creationReason: 'For testing purposes', + createdAt: 1745185445567, + updatedAt: 1745185445567, + }; + + it('getToken returns the token passed in', async () => { + const authObject = authenticatedMachineObject('machine_token', token, verificationResult); + const retrievedToken = await authObject.getToken(); + expect(retrievedToken).toBe(token); + }); + + it('has() always returns false', () => { + const authObject = authenticatedMachineObject('machine_token', token, verificationResult); + expect(authObject.has({})).toBe(false); + }); + + it('properly initializes properties', () => { + const authObject = authenticatedMachineObject('machine_token', token, verificationResult); + expect(authObject.tokenType).toBe('machine_token'); + expect(authObject.name).toBe('my-machine-token'); + expect(authObject.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + 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.claims).toBeNull(); + }); + + it('has() always returns false', () => { + const authObject = unauthenticatedMachineObject('machine_token'); + expect(authObject.has()).toBe(false); + }); + + it('getToken always returns null ', async () => { + const authObject = unauthenticatedMachineObject('machine_token'); + const retrievedToken = await authObject.getToken(); + expect(retrievedToken).toBeNull(); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/authStatus.test.ts b/packages/backend/src/tokens/__tests__/authStatus.test.ts index 87b50c567b1..e2056cf6875 100644 --- a/packages/backend/src/tokens/__tests__/authStatus.test.ts +++ b/packages/backend/src/tokens/__tests__/authStatus.test.ts @@ -1,43 +1,155 @@ +import type { JwtPayload } from '@clerk/types'; import { describe, expect, it } from 'vitest'; +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: 'api_key_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9=', + machineData: { + id: 'api_key_ey966f1b1xf93586b2debdcadb0b3bd1', + type: 'api_key', + name: 'my-api-key', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + createdBy: null, + creationReason: 'For testing purposes', + secondsUntilExpiration: null, + createdAt: 1745185445567, + expiresAt: 1745185445567, + }, + }); + + 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: 'api_key_LCWGdaM8mv8K4PC/57IICZQXAeWfCgF30DZaFXHoGn9=', + machineData: { + id: 'api_key_ey966f1b1xf93586b2debdcadb0b3bd1', + type: 'api_key', + name: 'my-api-key', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + createdBy: null, + creationReason: 'For testing purposes', + secondsUntilExpiration: null, + createdAt: 1745185445567, + expiresAt: 1745185445567, + }, + }).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 00000000000..41ff82fd7bc --- /dev/null +++ b/packages/backend/src/tokens/__tests__/machine.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { API_KEY_PREFIX, getMachineTokenType, isMachineToken, 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'); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index c3409deceed..0385fb6f580 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,13 @@ import { mockJwtPayload, mockMalformedJwt, } from '../../fixtures'; +import { + mockApiKey, + mockMachineAuthResponses, + mockMachineToken, + mockOauthToken, + tokenMap, +} from '../../fixtures/machine'; import { server } from '../../mock-server'; import type { AuthReason } from '../authStatus'; import { AuthErrorReason, AuthStatus } from '../authStatus'; @@ -20,7 +27,7 @@ import { type OrganizationSyncTarget, RefreshTokenErrorReason, } from '../request'; -import type { AuthenticateRequestOptions, OrganizationSyncOptions } from '../types'; +import type { AuthenticateRequestOptions, NonSessionTokenType, OrganizationSyncOptions } from '../types'; const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; @@ -31,6 +38,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 +229,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: NonSessionTokenType; + 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: NonSessionTokenType; + }, + ) { + 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 +309,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 +327,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 +1210,176 @@ 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 = tokenMap[tokenType]; + const mockConfig = mockMachineAuthResponses[tokenType]; + + test('returns authenticated state with valid token', async () => { + server.use( + http.post(mockConfig.endpoint, () => { + return HttpResponse.json(mockConfig.successResponse); + }), + ); + + 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 = tokenMap[tokenType]; + const mockConfig = mockMachineAuthResponses[tokenType]; + + server.use( + http.post(mockConfig.endpoint, () => { + return HttpResponse.json(mockConfig.successResponse); + }), + ); + + 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 ${mockApiKey}` }); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'oauth_token' })); + + expect(result).toBeMachineUnauthenticated({ + tokenType: 'api_key', + reason: 'token-type-mismatch', + 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 ${mockOauthToken}` }); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'machine_token' })); + + expect(result).toBeMachineUnauthenticated({ + tokenType: 'oauth_token', + reason: 'token-type-mismatch', + 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 ${mockMachineToken}` }); + const result = await authenticateRequest(request, mockOptions({ acceptsToken: 'api_key' })); + + expect(result).toBeMachineUnauthenticated({ + tokenType: 'machine_token', + reason: 'token-type-mismatch', + 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: 'token-type-mismatch', + 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(mockMachineAuthResponses.api_key.successResponse); + }), + ); + + const request = mockRequest({ authorization: `Bearer ${mockApiKey}` }); + 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 ${mockMachineToken}` }); + const requestState = await authenticateRequest( + request, + mockOptions({ acceptsToken: ['api_key', 'oauth_token'] }), + ); + + expect(requestState).toBeMachineUnauthenticated({ + tokenType: 'machine_token', + reason: 'token-type-mismatch', + message: '', + }); + expect(requestState.toAuth()).toBeMachineUnauthenticatedToAuth({ + tokenType: 'machine_token', + }); + }); + }); + }); }); diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index 895f948b064..e408e5daef5 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 { ObjectType } from '../../api'; import { mockJwks, mockJwt, mockJwtPayload } from '../../fixtures'; import { server, validateHeaders } from '../../mock-server'; -import { verifyToken } from '../verify'; +import { verifyMachineAuthToken, verifyToken } from '../verify'; describe('tokens.verify(token, options)', () => { beforeEach(() => { @@ -53,3 +55,266 @@ 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/v1/api_keys/verify', + validateHeaders(() => { + return HttpResponse.json({ + object: ObjectType.ApiKey, + id: 'api_key_ey966f1b1xf93586b2debdcadb0b3bd1', + type: 'api_key', + name: 'my-api-key', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + createdBy: null, + creationReason: 'For testing purposes', + secondsUntilExpiration: null, + createdAt: 1745185445567, + expiresAt: 1745185445567, + }); + }), + ), + ); + + 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.claims).toEqual({ foo: 'bar' }); + }); + + it('verifies provided Machine token', async () => { + const token = 'm2m_8XOIucKvqHVr5tYP123456789abcdefghij'; + + server.use( + http.post( + 'https://api.clerk.test/v1/m2m_tokens/verify', + validateHeaders(() => { + return HttpResponse.json({ + object: ObjectType.MachineToken, + id: 'm2m_ey966f1b1xf93586b2debdcadb0b3bd1', + name: 'my-machine-token', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { foo: 'bar' }, + revoked: false, + expired: false, + expiration: 1745185445567, + createdBy: null, + creationReason: 'For testing purposes', + createdAt: 1745185445567, + updatedAt: 1745185445567, + }); + }), + ), + ); + + 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.claims).toEqual({ foo: 'bar' }); + }); + + it('verifies provided OAuth token', async () => { + const token = 'oauth_token_8XOIucKvqHVr5tYP123456789abcdefghij'; + + server.use( + http.post( + 'https://api.clerk.test/v1/oauth_applications/access_tokens/verify', + validateHeaders(() => { + return HttpResponse.json({ + object: ObjectType.IdpOAuthAccessToken, + id: 'oauth_token_2VTWUzvGC5UhdJCNx6xG1D98edc', + type: 'oauth:access_token', + name: 'GitHub OAuth', + subject: 'user_2vYVtestTESTtestTESTtestTESTtest', + claims: { scope: 'read write' }, + createdAt: 1744928754551, + expiresAt: 1744928754551, + }); + }), + ), + ); + + 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_token_2VTWUzvGC5UhdJCNx6xG1D98edc'); + expect(data.name).toBe('GitHub OAuth'); + expect(data.subject).toBe('user_2vYVtestTESTtestTESTtestTESTtest'); + expect(data.claims).toEqual({ scope: 'read write' }); + }); + + 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/v1/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/v1/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/v1/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/v1/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_invalid_token'; + + server.use( + http.post('https://api.clerk.test/v1/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_token_8XOIucKvqHVr5tYP123456789abcdefghij'; + + server.use( + http.post('https://api.clerk.test/v1/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 df0837eb19d..5349e778d61 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -11,6 +11,7 @@ import type { import type { CreateBackendApiOptions } from '../api'; import { createBackendApiClient } from '../api'; import type { AuthenticateContext } from './authenticateContext'; +import type { MachineAuthType, NonSessionTokenType } from './types'; type AuthObjectDebugData = Record; type AuthObjectDebug = () => AuthObjectDebugData; @@ -26,6 +27,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & { * @internal */ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { + tokenType: 'session_token'; getToken: ServerGetToken; has: CheckAuthorizationFromSessionClaims; debug: AuthObjectDebug; @@ -39,6 +41,7 @@ export type SignedOutAuthObject = { sessionId: null; sessionStatus: null; actor: null; + tokenType: 'session_token'; userId: null; orgId: null; orgRole: null; @@ -58,7 +61,36 @@ export type SignedOutAuthObject = { /** * @internal */ -export type AuthObject = SignedInAuthObject | SignedOutAuthObject; +export type AuthenticatedMachineObject = { + tokenType: NonSessionTokenType; + id: string; + name: string; + subject: string; + claims: Record | null; + getToken: () => Promise; + has: CheckAuthorizationFromSessionClaims; + debug: AuthObjectDebug; +}; + +/** + * @internal + */ +export type UnauthenticatedMachineObject = { + tokenType: NonSessionTokenType; + id: null; + name: null; + subject: null; + claims: null; + getToken: () => Promise; + has: () => false; + debug: AuthObjectDebug; +}; + +export type AuthObject = + | SignedInAuthObject + | SignedOutAuthObject + | AuthenticatedMachineObject + | UnauthenticatedMachineObject; const createDebug = (data: AuthObjectDebugData | undefined) => { return () => { @@ -86,6 +118,7 @@ export function signedInAuthObject( fetcher: async (...args) => (await apiClient.sessions.getToken(...args)).jwt, }); return { + tokenType: 'session_token', actor, sessionClaims, sessionId, @@ -115,6 +148,7 @@ export function signedInAuthObject( */ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutAuthObject { return { + tokenType: 'session_token', sessionClaims: null, sessionId: null, sessionStatus: null, @@ -131,6 +165,46 @@ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutA }; } +/** + * @internal + */ +export function authenticatedMachineObject( + tokenType: NonSessionTokenType, + machineToken: string, + verificationResult: MachineAuthType, + debugData?: AuthObjectDebugData, +): AuthenticatedMachineObject { + return { + tokenType, + id: verificationResult.id, + name: verificationResult.name, + subject: verificationResult.subject, + claims: verificationResult.claims, + getToken: () => Promise.resolve(machineToken), + has: () => false, + debug: createDebug(debugData), + }; +} + +/** + * @internal + */ +export function unauthenticatedMachineObject( + tokenType: NonSessionTokenType, + debugData?: AuthObjectDebugData, +): UnauthenticatedMachineObject { + return { + tokenType, + id: null, + name: null, + subject: null, + claims: null, + getToken: () => Promise.resolve(null), + has: () => false, + debug: createDebug(debugData), + }; +} + /** * 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 0d73f3e2539..8bd32adb165 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, NonSessionTokenType, TokenType } from './types'; export const AuthStatus = { SignedIn: 'signed-in', @@ -14,7 +25,7 @@ export const AuthStatus = { export type AuthStatus = (typeof AuthStatus)[keyof typeof AuthStatus]; -export type SignedInState = { +export type AuthenticatedState = { status: typeof AuthStatus.SignedIn; reason: null; message: null; @@ -26,16 +37,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: T extends 'session_token' ? () => SignedInAuthObject : () => AuthenticatedMachineObject & { tokenType: T }; }; -export type SignedOutState = { +export type UnauthenticatedState = { status: typeof AuthStatus.SignedOut; - message: string; reason: AuthReason; + message: string; proxyUrl?: string; publishableKey: string; isSatellite: boolean; @@ -44,18 +60,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: T extends 'session_token' ? () => SignedOutAuthObject : () => UnauthenticatedMachineObject & { tokenType: T }; }; -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 +102,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 +110,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: NonSessionTokenType; 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); + }; -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 +152,30 @@ export function signedIn( afterSignInUrl: authenticateContext.afterSignInUrl || '', afterSignUpUrl: authenticateContext.afterSignUpUrl || '', isSignedIn: true, - toAuth: () => authObject, + isAuthenticated: true, + tokenType: params.tokenType, + toAuth, headers, token, - }; + } as AuthenticatedState; } -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 }); + }; + return withDebugHeaders({ status: AuthStatus.SignedOut, reason, @@ -124,10 +189,12 @@ 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, - }); + }) as UnauthenticatedState; } export function handshake( @@ -149,13 +216,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/factory.ts b/packages/backend/src/tokens/factory.ts index c6369d2baf4..42aecbc905b 100644 --- a/packages/backend/src/tokens/factory.ts +++ b/packages/backend/src/tokens/factory.ts @@ -1,7 +1,8 @@ import type { ApiClient } from '../api'; +import type { RequestState } from '../tokens/authStatus'; import { mergePreDefinedOptions } from '../util/mergePreDefinedOptions'; import { authenticateRequest as authenticateRequestOriginal, debugRequestState } from './request'; -import type { AuthenticateRequestOptions } from './types'; +import type { AuthenticateRequestOptions, TokenType, UniqueTokenArray } from './types'; type RunTimeOptions = Omit; type BuildTimeOptions = Partial< @@ -46,7 +47,26 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio const buildTimeOptions = mergePreDefinedOptions(defaultOptions, params.options); const apiClient = params.apiClient; - const authenticateRequest = (request: Request, options: RunTimeOptions = {}) => { + // No options case + function authenticateRequest(request: Request): Promise>; + // With options but no acceptsToken case + function authenticateRequest( + request: Request, + options: Omit, + ): Promise>; + // Single or any token type case + function authenticateRequest( + request: Request, + options: RunTimeOptions & { acceptsToken: T }, + ): Promise>; + // List of unique token types case + function authenticateRequest( + request: Request, + options: RunTimeOptions & { acceptsToken: T }, + ): Promise>; + + // Implementation + function authenticateRequest(request: Request, options: RunTimeOptions = {}): Promise> { const { apiUrl, apiVersion } = buildTimeOptions; const runTimeOptions = mergePreDefinedOptions(buildTimeOptions, options); return authenticateRequestOriginal(request, { @@ -58,7 +78,7 @@ export function createAuthenticateRequest(params: CreateAuthenticateRequestOptio apiVersion, apiClient, }); - }; + } return { authenticateRequest, diff --git a/packages/backend/src/tokens/machine.ts b/packages/backend/src/tokens/machine.ts new file mode 100644 index 00000000000..ce4b18a4e0d --- /dev/null +++ b/packages/backend/src/tokens/machine.ts @@ -0,0 +1,27 @@ +import type { NonSessionTokenType } from '../tokens/types'; + +export const M2M_TOKEN_PREFIX = 'm2m_'; +export const OAUTH_TOKEN_PREFIX = 'oauth_'; +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): NonSessionTokenType { + 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'); +} diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 9d714029618..1cdc3194cfa 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -4,20 +4,27 @@ 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 } from './machine'; +import type { + AuthenticateRequestOptions, + NonSessionTokenType, + OrganizationSyncOptions, + TokenType, + UniqueTokenArray, +} from './types'; +import { verifyMachineAuthToken, verifyToken } from './verify'; export const RefreshTokenErrorReason = { NonEligibleNoCookie: 'non-eligible-no-refresh-cookie', @@ -90,13 +97,59 @@ function isRequestEligibleForRefresh( ); } +function maybeHandleTokenTypeMismatch( + parsedTokenType: NonSessionTokenType, + acceptsToken: TokenType | TokenType[] | 'any', + authenticateContext: AuthenticateContext, +): UnauthenticatedState | null { + if (acceptsToken === 'any') { + return null; + } + const mismatch = Array.isArray(acceptsToken) + ? !acceptsToken.includes(parsedTokenType) + : acceptsToken !== parsedTokenType; + if (mismatch) { + return signedOut({ + tokenType: parsedTokenType, + authenticateContext, + reason: AuthErrorReason.TokenTypeMismatch, + }); + } + return null; +} + +// No options case. +export async function authenticateRequest( + request: Request, + options: AuthenticateRequestOptions, +): Promise>; +// With options but no acceptsToken case +export async function authenticateRequest( + request: Request, + options: Omit, +): Promise>; +// Single or any token type case. +export async function authenticateRequest( + request: Request, + options: AuthenticateRequestOptions & { acceptsToken: T }, +): Promise>; +// List of unique token types case. +export async function authenticateRequest( + request: Request, + options: AuthenticateRequestOptions & { acceptsToken: T }, +): Promise>; + +// Implementation export async function authenticateRequest( 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 +230,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 +273,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 +442,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, + }); } /** @@ -452,8 +532,14 @@ ${error.getFullMessage()}`, 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: sessionTokenInHeader!, + }); } catch (err) { return handleError(err, 'header'); } @@ -585,7 +671,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 +704,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 +728,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 +741,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 +753,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,11 +795,125 @@ ${error.getFullMessage()}`, ); } - return signedOut(authenticateContext, err.reason, err.getFullMessage()); + return signedOut({ + tokenType: 'session_token', + authenticateContext, + reason: err.reason, + message: err.getFullMessage(), + }); + } + + function handleMachineError(tokenType: NonSessionTokenType, 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 { sessionTokenInHeader } = authenticateContext; + if (!sessionTokenInHeader) { + return handleError(new Error('No token in header'), 'header'); + } + + // Handle case where tokenType is any and the token is not a machine token + if (!isMachineToken(sessionTokenInHeader)) { + return signedOut({ + tokenType: acceptsToken as NonSessionTokenType, + authenticateContext, + reason: AuthErrorReason.TokenTypeMismatch, + message: '', + }); + } + + const parsedTokenType = getMachineTokenType(sessionTokenInHeader); + const mismatchState = maybeHandleTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); + if (mismatchState) { + return mismatchState; + } + + const { data, tokenType, errors } = await verifyMachineAuthToken(sessionTokenInHeader, authenticateContext); + if (errors) { + return handleMachineError(tokenType, errors[0]); + } + return signedIn({ + tokenType, + authenticateContext, + machineData: data, + token: sessionTokenInHeader, + }); + } + + async function authenticateAnyRequestWithTokenInHeader() { + const { sessionTokenInHeader } = authenticateContext; + if (!sessionTokenInHeader) { + return handleError(new Error('No token in header'), 'header'); + } + + // Handle as a machine token + if (isMachineToken(sessionTokenInHeader)) { + const parsedTokenType = getMachineTokenType(sessionTokenInHeader); + const mismatchState = maybeHandleTokenTypeMismatch(parsedTokenType, acceptsToken, authenticateContext); + if (mismatchState) { + return mismatchState; + } + + const { data, tokenType, errors } = await verifyMachineAuthToken(sessionTokenInHeader, authenticateContext); + if (errors) { + return handleMachineError(tokenType, errors[0]); + } + + return signedIn({ + tokenType, + authenticateContext, + machineData: data, + token: sessionTokenInHeader, + }); + } + + // Handle as a regular session token + const { data, errors } = await verifyToken(sessionTokenInHeader, authenticateContext); + if (errors) { + return handleError(errors[0], 'header'); + } + + return signedIn({ + tokenType: 'session_token', + authenticateContext, + sessionClaims: data, + token: sessionTokenInHeader, + }); } if (authenticateContext.sessionTokenInHeader) { - return authenticateRequestWithTokenInHeader(); + 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(); diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index f96417c8182..d78b4bd2a06 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,31 @@ export type OrganizationSyncOptions = { * ``` */ type Pattern = string; + +export type TokenType = 'session_token' | 'oauth_token' | 'api_key' | 'machine_token'; + +export type NonSessionTokenType = Exclude; + +export type MachineAuthType = MachineToken | APIKey | IdPOAuthAccessToken; + +/** + * A type that ensures an array contains only unique token types from the allowed TokenType values. + */ +export type UniqueTokenArray< + ExcludedTokens = never, + MaxLength extends 4 | 3 | 2 | 1 | 0 = 4, // Updated to support 4 tokens +> = MaxLength extends 0 + ? [] + : Exclude extends infer T + ? T extends any + ? + | [ + T, + ...UniqueTokenArray< + ExcludedTokens | T, + MaxLength extends 4 ? 3 : MaxLength extends 3 ? 2 : MaxLength extends 2 ? 1 : 0 + >, + ] + | [] + : never + : never; diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 3a2b2d7d497..88fdc876b84 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 { NonSessionTokenType } 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: NonSessionTokenType, + 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 4e90ff94ccd..925cb39e4de 100644 --- a/packages/backend/src/util/decorateObjectWithResources.ts +++ b/packages/backend/src/util/decorateObjectWithResources.ts @@ -1,6 +1,6 @@ import type { CreateBackendApiOptions, Organization, Session, User } from '../api'; import { createBackendApiClient } from '../api'; -import type { AuthObject } from '../tokens/authObjects'; +import type { AuthObject, SignedInAuthObject, SignedOutAuthObject } from '../tokens/authObjects'; type DecorateAuthWithResourcesOptions = { loadSession?: boolean; @@ -23,7 +23,7 @@ export const decorateObjectWithResources = async ( opts: CreateBackendApiOptions & DecorateAuthWithResourcesOptions, ): Promise> => { const { loadSession, loadUser, loadOrganization } = opts || {}; - const { userId, sessionId, orgId } = authObj; + const { userId, sessionId, orgId } = authObj as SignedInAuthObject | SignedOutAuthObject; const { sessions, users, organizations } = createBackendApiClient({ ...opts }); diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 019b7e23fd3..f203200ff39 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 c53bb104a5a..4162a0aeb55 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 2d982c81187..13737834926 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2969,7 +2969,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==}