diff --git a/.changeset/better-vans-obey.md b/.changeset/better-vans-obey.md new file mode 100644 index 00000000000..86bf3d4e8c8 --- /dev/null +++ b/.changeset/better-vans-obey.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Initial stub of the new handshake payload flow with nonce diff --git a/packages/backend/src/api/endpoints/HandshakePayloadApi.ts b/packages/backend/src/api/endpoints/HandshakePayloadApi.ts new file mode 100644 index 00000000000..5bdf9f24404 --- /dev/null +++ b/packages/backend/src/api/endpoints/HandshakePayloadApi.ts @@ -0,0 +1,18 @@ +import type { HandshakePayload } from '../resources/HandshakePayload'; +import { AbstractAPI } from './AbstractApi'; + +const BASE_PATH = '/handshake_payload'; + +type GetHandshakePayloadParams = { + nonce: string; +}; + +export class HandshakePayloadAPI extends AbstractAPI { + public async getHandshakePayload(queryParams: GetHandshakePayloadParams) { + return this.request({ + method: 'GET', + path: BASE_PATH, + queryParams, + }); + } +} diff --git a/packages/backend/src/api/resources/HandshakePayload.ts b/packages/backend/src/api/resources/HandshakePayload.ts new file mode 100644 index 00000000000..229acb92951 --- /dev/null +++ b/packages/backend/src/api/resources/HandshakePayload.ts @@ -0,0 +1,15 @@ +export type HandshakePayloadJSON = { + nonce: string; + payload: string; +}; + +export class HandshakePayload { + constructor( + readonly nonce: string, + readonly payload: string, + ) {} + + static fromJSON(data: HandshakePayloadJSON): HandshakePayload { + return new HandshakePayload(data.nonce, data.payload); + } +} diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index ee7adae57ef..08e90a3164f 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -21,6 +21,7 @@ const Cookies = { Handshake: '__clerk_handshake', DevBrowser: '__clerk_db_jwt', RedirectCount: '__clerk_redirect_count', + HandshakeNonce: '__clerk_handshake_nonce', } as const; const QueryParameters = { @@ -33,6 +34,7 @@ const QueryParameters = { HandshakeHelp: '__clerk_help', LegacyDevBrowser: '__dev_session', HandshakeReason: '__clerk_hs_reason', + HandshakeNonce: Cookies.HandshakeNonce, } as const; const Headers = { diff --git a/packages/backend/src/tokens/__tests__/handshake.test.ts b/packages/backend/src/tokens/__tests__/handshake.test.ts new file mode 100644 index 00000000000..ba2cf519b42 --- /dev/null +++ b/packages/backend/src/tokens/__tests__/handshake.test.ts @@ -0,0 +1,391 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { constants } from '../../constants'; +import { TokenVerificationError, TokenVerificationErrorReason } from '../../errors'; +import type { AuthenticateContext } from '../authenticateContext'; +import { AuthErrorReason, signedIn, signedOut } from '../authStatus'; +import { HandshakeService } from '../handshake'; +import { OrganizationMatcher } from '../organizationMatcher'; + +vi.mock('../handshake.js', async importOriginal => { + const actual: any = await importOriginal(); + return { + ...actual, + verifyHandshakeToken: vi.fn().mockResolvedValue({ + handshake: ['cookie1=value1', 'session=session-token'], + }), + }; +}); + +vi.mock('../verify.js', async importOriginal => { + const actual: any = await importOriginal(); + return { + ...actual, + verifyToken: vi.fn(), + }; +}); + +vi.mock('../../jwt/verifyJwt.js', () => ({ + decodeJwt: vi.fn().mockReturnValue({ + data: { + header: { typ: 'JWT', alg: 'RS256', kid: 'test-kid' }, + payload: { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + signature: new Uint8Array([1, 2, 3]), + raw: { + header: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9', + payload: 'eyJzdWIiOiJ1c2VyXzEyMyJ9', + signature: 'signature', + text: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.signature', + }, + }, + errors: undefined, + }), +})); + +describe('HandshakeService', () => { + let mockAuthenticateContext: AuthenticateContext; + let mockOrganizationMatcher: OrganizationMatcher; + let mockOptions: { + organizationSyncOptions?: { organizationPatterns?: string[]; personalAccountPatterns?: string[] }; + }; + let handshakeService: HandshakeService; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAuthenticateContext = { + clerkUrl: new URL('https://example.com'), + frontendApi: 'api.clerk.com', + instanceType: 'production', + usesSuffixedCookies: () => true, + secFetchDest: 'document', + accept: 'text/html', + } as AuthenticateContext; + + mockOrganizationMatcher = new OrganizationMatcher({ + organizationPatterns: ['/org/:id'], + personalAccountPatterns: ['/account'], + }); + + mockOptions = { + organizationSyncOptions: { + organizationPatterns: ['/org/:id'], + personalAccountPatterns: ['/account'], + }, + }; + + handshakeService = new HandshakeService(mockAuthenticateContext, mockOptions, mockOrganizationMatcher); + }); + + describe('isRequestEligibleForHandshake', () => { + it('should return true for document secFetchDest', () => { + mockAuthenticateContext.secFetchDest = 'document'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(true); + }); + + it('should return true for iframe secFetchDest', () => { + mockAuthenticateContext.secFetchDest = 'iframe'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(true); + }); + + it('should return true for text/html accept header without secFetchDest', () => { + mockAuthenticateContext.secFetchDest = undefined; + mockAuthenticateContext.accept = 'text/html'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(true); + }); + + it('should return false for non-eligible requests', () => { + mockAuthenticateContext.secFetchDest = 'image'; + mockAuthenticateContext.accept = 'image/png'; + expect(handshakeService.isRequestEligibleForHandshake()).toBe(false); + }); + }); + + describe('buildRedirectToHandshake', () => { + it('should build redirect headers with basic parameters', () => { + const headers = handshakeService.buildRedirectToHandshake('test-reason'); + const location = headers.get(constants.Headers.Location); + if (!location) { + throw new Error('Location header is missing'); + } + const url = new URL(location); + + expect(url.hostname).toBe('api.clerk.com'); + expect(url.pathname).toBe('/v1/client/handshake'); + expect(url.searchParams.get('redirect_url')).toBe('https://example.com/'); + expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toBe('true'); + expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason'); + }); + + it('should include dev browser token in development mode', () => { + mockAuthenticateContext.instanceType = 'development'; + mockAuthenticateContext.devBrowserToken = 'dev-token'; + const headers = handshakeService.buildRedirectToHandshake('test-reason'); + const location = headers.get(constants.Headers.Location); + if (!location) { + throw new Error('Location header is missing'); + } + const url = new URL(location); + + expect(url.searchParams.get(constants.QueryParameters.DevBrowser)).toBe('dev-token'); + }); + + it('should throw error if clerkUrl is missing', () => { + mockAuthenticateContext.clerkUrl = undefined as any; + expect(() => handshakeService.buildRedirectToHandshake('test-reason')).toThrow( + 'Missing clerkUrl in authenticateContext', + ); + }); + }); + + describe.skip('resolveHandshake', () => { + it('should resolve handshake with valid token', async () => { + const mockJwt = { + header: { + typ: 'JWT', + alg: 'RS256', + kid: 'test-kid', + }, + payload: { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + signature: new Uint8Array([1, 2, 3]), + raw: { + header: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9', + payload: 'eyJzdWIiOiJ1c2VyXzEyMyJ9', + signature: 'signature', + text: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.signature', + }, + }; + const mockHandshakePayload = { + handshake: ['cookie1=value1', 'session=session-token'], + }; + + const mockVerifyToken = vi.mocked(await import('../handshake.js')).verifyHandshakeToken; + mockVerifyToken.mockResolvedValue(mockHandshakePayload); + + const mockVerifyTokenResult = vi.mocked(await import('../verify.js')).verifyToken; + mockVerifyTokenResult.mockResolvedValue({ + data: mockJwt.payload, + errors: undefined, + }); + + const mockDecodeJwt = vi.mocked(await import('../../jwt/verifyJwt.js')).decodeJwt; + mockDecodeJwt.mockReturnValue({ + data: mockJwt, + errors: undefined, + }); + + vi.mocked(await import('../handshake.js')).verifyHandshakeToken.mockResolvedValue(mockHandshakePayload); + + mockAuthenticateContext.handshakeToken = 'any-token'; + const result = await handshakeService.resolveHandshake(); + + expect(result).toEqual( + signedIn( + mockAuthenticateContext, + { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + expect.any(Headers), + 'session-token', + ), + ); + }); + + it('should handle missing session token', async () => { + const mockHandshakePayload = { handshake: ['cookie1=value1'] }; + const mockVerifyToken = vi.mocked(await import('../handshake.js')).verifyHandshakeToken; + mockVerifyToken.mockResolvedValue(mockHandshakePayload); + + mockAuthenticateContext.handshakeToken = 'valid-token'; + const result = await handshakeService.resolveHandshake(); + + expect(result).toEqual( + signedOut(mockAuthenticateContext, AuthErrorReason.SessionTokenMissing, '', expect.any(Headers)), + ); + }); + + it('should handle development mode clock skew', async () => { + mockAuthenticateContext.instanceType = 'development'; + + const mockJwt = { + header: { + typ: 'JWT', + alg: 'RS256', + kid: 'test-kid', + }, + payload: { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + signature: new Uint8Array([1, 2, 3]), + raw: { + header: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9', + payload: 'eyJzdWIiOiJ1c2VyXzEyMyJ9', + signature: 'signature', + text: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.signature', + }, + }; + const mockHandshakePayload = { + handshake: ['cookie1=value1', 'session=session-token'], + }; + + const mockVerifyToken = vi.mocked(await import('../handshake.js')).verifyHandshakeToken; + mockVerifyToken.mockResolvedValue(mockHandshakePayload); + + const mockVerifyTokenResult = vi.mocked(await import('../verify.js')).verifyToken; + mockVerifyTokenResult + .mockRejectedValueOnce( + new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenExpired, + message: 'Token expired', + }), + ) + .mockResolvedValueOnce({ + data: mockJwt.payload, + errors: undefined, + }); + + const mockDecodeJwt = vi.mocked(await import('../../jwt/verifyJwt.js')).decodeJwt; + mockDecodeJwt.mockReturnValue({ + data: mockJwt, + errors: undefined, + }); + + // Mock verifyHandshakeToken to return our mock data directly + vi.mocked(await import('../handshake.js')).verifyHandshakeToken.mockResolvedValue(mockHandshakePayload); + + mockAuthenticateContext.handshakeToken = 'any-token'; + const result = await handshakeService.resolveHandshake(); + + expect(result).toEqual( + signedIn( + mockAuthenticateContext, + { + sub: 'user_123', + __raw: 'raw-token', + iss: 'issuer', + sid: 'session-id', + nbf: 1234567890, + exp: 1234567890, + iat: 1234567890, + v: 2 as const, + fea: undefined, + pla: undefined, + o: undefined, + org_permissions: undefined, + org_id: undefined, + org_slug: undefined, + org_role: undefined, + }, + expect.any(Headers), + 'session-token', + ), + ); + }); + }); + + describe('handleTokenVerificationErrorInDevelopment', () => { + it('should throw specific error for invalid signature', () => { + const error = new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenInvalidSignature, + message: 'Invalid signature', + }); + + expect(() => handshakeService.handleTokenVerificationErrorInDevelopment(error)).toThrow( + 'Clerk: Handshake token verification failed due to an invalid signature', + ); + }); + + it('should throw generic error for other verification failures', () => { + const error = new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenExpired, + message: 'Token expired', + }); + + expect(() => handshakeService.handleTokenVerificationErrorInDevelopment(error)).toThrow( + 'Clerk: Handshake token verification failed: Token expired', + ); + }); + }); + + describe('checkAndTrackRedirectLoop', () => { + it('should return true after 3 redirects', () => { + const headers = new Headers(); + handshakeService['redirectLoopCounter'] = 3; + + const result = handshakeService.checkAndTrackRedirectLoop(headers); + + expect(result).toBe(true); + expect(headers.get('Set-Cookie')).toBeNull(); + }); + + it('should increment counter and set cookie for first redirect', () => { + const headers = new Headers(); + handshakeService['redirectLoopCounter'] = 0; + + const result = handshakeService.checkAndTrackRedirectLoop(headers); + + expect(result).toBe(false); + expect(headers.get('Set-Cookie')).toContain('__clerk_redirect_count=1'); + }); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/organizationMatcher.test.ts b/packages/backend/src/tokens/__tests__/organizationMatcher.test.ts new file mode 100644 index 00000000000..00c35acffea --- /dev/null +++ b/packages/backend/src/tokens/__tests__/organizationMatcher.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { OrganizationMatcher } from '../organizationMatcher'; + +describe('OrganizationMatcher', () => { + describe('constructor', () => { + it('should create matcher with no patterns when options are undefined', () => { + const matcher = new OrganizationMatcher(); + expect(matcher).toBeInstanceOf(OrganizationMatcher); + }); + + it('should create matcher with organization patterns', () => { + const matcher = new OrganizationMatcher({ + organizationPatterns: ['/orgs/:id'], + }); + expect(matcher).toBeInstanceOf(OrganizationMatcher); + }); + + it('should create matcher with personal account patterns', () => { + const matcher = new OrganizationMatcher({ + personalAccountPatterns: ['/account'], + }); + expect(matcher).toBeInstanceOf(OrganizationMatcher); + }); + + it('should throw error for invalid organization pattern', () => { + expect(() => { + new OrganizationMatcher({ + organizationPatterns: ['/orgs/:id/***'], // Definitely invalid pattern + }); + }).toThrow(/Invalid pattern/); + }); + + it('should throw error for invalid personal account pattern', () => { + expect(() => { + new OrganizationMatcher({ + personalAccountPatterns: ['/account/***'], // Definitely invalid pattern + }); + }).toThrow(/Invalid pattern/); + }); + }); + + describe('findTarget', () => { + it('should return null for no patterns', () => { + const matcher = new OrganizationMatcher(); + const url = new URL('http://localhost:3000/orgs/123'); + expect(matcher.findTarget(url)).toBeNull(); + }); + + it('should find organization by ID', () => { + const matcher = new OrganizationMatcher({ + organizationPatterns: ['/orgs/:id'], + }); + const url = new URL('http://localhost:3000/orgs/123'); + expect(matcher.findTarget(url)).toEqual({ + type: 'organization', + organizationId: '123', + }); + }); + + it('should find organization by slug', () => { + const matcher = new OrganizationMatcher({ + organizationPatterns: ['/orgs/:slug'], + }); + const url = new URL('http://localhost:3000/orgs/my-org'); + expect(matcher.findTarget(url)).toEqual({ + type: 'organization', + organizationSlug: 'my-org', + }); + }); + + it('should find personal account', () => { + const matcher = new OrganizationMatcher({ + personalAccountPatterns: ['/account'], + }); + const url = new URL('http://localhost:3000/account'); + expect(matcher.findTarget(url)).toEqual({ + type: 'personalAccount', + }); + }); + + it('should prioritize organization over personal account', () => { + const matcher = new OrganizationMatcher({ + organizationPatterns: ['/orgs/:id'], + personalAccountPatterns: ['/orgs/:id'], // Same pattern + }); + const url = new URL('http://localhost:3000/orgs/123'); + expect(matcher.findTarget(url)).toEqual({ + type: 'organization', + organizationId: '123', + }); + }); + + it('should handle nested paths', () => { + const matcher = new OrganizationMatcher({ + organizationPatterns: ['/orgs/:id/(.*)'], + }); + const url = new URL('http://localhost:3000/orgs/123/settings'); + expect(matcher.findTarget(url)).toEqual({ + type: 'organization', + organizationId: '123', + }); + }); + + it('should handle multiple patterns', () => { + const matcher = new OrganizationMatcher({ + organizationPatterns: ['/orgs/:id', '/teams/:id'], + }); + const url = new URL('http://localhost:3000/teams/123'); + expect(matcher.findTarget(url)).toEqual({ + type: 'organization', + organizationId: '123', + }); + }); + + it('should return null for non-matching paths', () => { + const matcher = new OrganizationMatcher({ + organizationPatterns: ['/orgs/:id'], + }); + const url = new URL('http://localhost:3000/other/123'); + expect(matcher.findTarget(url)).toBeNull(); + }); + }); + + describe('pathToRegexp behavior', () => { + it('should throw error for invalid pattern', () => { + expect(() => { + match(['/orgs/:id/***']); + }).toThrow(); + }); + }); +}); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index c3409deceed..16142791ab1 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from 'msw'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import { TokenVerificationErrorReason } from '../../errors'; import { @@ -13,14 +13,9 @@ import { import { server } from '../../mock-server'; import type { AuthReason } from '../authStatus'; import { AuthErrorReason, AuthStatus } from '../authStatus'; -import { - authenticateRequest, - computeOrganizationSyncTargetMatchers, - getOrganizationSyncTarget, - type OrganizationSyncTarget, - RefreshTokenErrorReason, -} from '../request'; -import type { AuthenticateRequestOptions, OrganizationSyncOptions } from '../types'; +import { OrganizationMatcher } from '../organizationMatcher'; +import { authenticateRequest, RefreshTokenErrorReason } from '../request'; +import type { AuthenticateRequestOptions } from '../types'; const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA'; @@ -261,20 +256,8 @@ const mockRequestWithCookies = (headers?, cookies = {}, requestUrl?) => { return mockRequest({ cookie: cookieStr, ...headers }, requestUrl); }; -// Tests both getOrganizationSyncTarget and the organizationSyncOptions usage patterns -// that are recommended for typical use. -describe('tokens.getOrganizationSyncTarget(url,options)', _ => { - type testCase = { - name: string; - // When the customer app specifies these orgSyncOptions to middleware... - whenOrgSyncOptions: OrganizationSyncOptions | undefined; - // And the path arrives at this URL path... - whenAppRequestPath: string; - // A handshake should (or should not) occur: - thenExpectActivationEntity: OrganizationSyncTarget | null; - }; - - const testCases: testCase[] = [ +describe('getOrganizationSyncTarget', () => { + it.each([ { name: 'none activates nothing', whenOrgSyncOptions: undefined, @@ -426,16 +409,10 @@ describe('tokens.getOrganizationSyncTarget(url,options)', _ => { organizationSlug: 'org_bar', }, }, - ]; - - test.each(testCases)('$name', ({ name, whenOrgSyncOptions, whenAppRequestPath, thenExpectActivationEntity }) => { - if (!name) { - return; - } - - const path = new URL(`http://localhost:3000${whenAppRequestPath}`); - const matchers = computeOrganizationSyncTargetMatchers(whenOrgSyncOptions); - const toActivate = getOrganizationSyncTarget(path, whenOrgSyncOptions, matchers); + ])('$name', ({ whenOrgSyncOptions, whenAppRequestPath, thenExpectActivationEntity }) => { + const path = new URL(`http://localhost:3000${whenAppRequestPath || ''}`); + const matcher = new OrganizationMatcher(whenOrgSyncOptions); + const toActivate = matcher.findTarget(path); expect(toActivate).toEqual(thenExpectActivationEntity); }); }); diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 3f945c0a7c9..db3765be5de 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -25,8 +25,10 @@ interface AuthenticateContext extends AuthenticateRequestOptions { clientUat: number; // handshake-related values devBrowserToken: string | undefined; + handshakeNonce: string | undefined; handshakeToken: string | undefined; handshakeRedirectLoopCounter: number; + // url derived from headers clerkUrl: URL; // enforce existence of the following props @@ -198,6 +200,8 @@ class AuthenticateContext implements AuthenticateContext { this.handshakeToken = this.getQueryParam(constants.QueryParameters.Handshake) || this.getCookie(constants.Cookies.Handshake); this.handshakeRedirectLoopCounter = Number(this.getCookie(constants.Cookies.RedirectCount)) || 0; + this.handshakeNonce = + this.getQueryParam(constants.QueryParameters.HandshakeNonce) || this.getCookie(constants.Cookies.HandshakeNonce); } private getQueryParam(name: string) { diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 1ac4b95dc30..a6a1fe43d8a 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -1,9 +1,17 @@ +import { constants } from '../constants'; import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; import type { VerifyJwtOptions } from '../jwt'; import { assertHeaderAlgorithm, assertHeaderType } from '../jwt/assertions'; import { decodeJwt, hasValidSignature } from '../jwt/verifyJwt'; +import type { AuthenticateContext } from './authenticateContext'; +import type { SignedInState, SignedOutState } from './authStatus'; +import { AuthErrorReason, signedIn, signedOut } from './authStatus'; +import { getCookieName, getCookieValue } from './cookie'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; +import type { OrganizationMatcher } from './organizationMatcher'; +import type { OrganizationSyncOptions, OrganizationSyncTarget } from './types'; import type { VerifyTokenOptions } from './verify'; +import { verifyToken } from './verify'; async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Promise<{ handshake: string[] }> { const { data: decoded, errors } = decodeJwt(token); @@ -73,3 +81,233 @@ export async function verifyHandshakeToken( key, }); } + +export class HandshakeService { + private redirectLoopCounter: number; + private readonly authenticateContext: AuthenticateContext; + private readonly organizationMatcher: OrganizationMatcher; + private readonly options: { organizationSyncOptions?: OrganizationSyncOptions }; + + constructor( + authenticateContext: AuthenticateContext, + options: { organizationSyncOptions?: OrganizationSyncOptions }, + organizationMatcher: OrganizationMatcher, + ) { + this.authenticateContext = authenticateContext; + this.options = options; + this.organizationMatcher = organizationMatcher; + this.redirectLoopCounter = 0; + } + + /** + * Determines if a request is eligible for handshake based on its headers + * + * Currently, a request is only eligible for a handshake if we can say it's *probably* a request for a document, not a fetch or some other exotic request. + * This heuristic should give us a reliable enough signal for browsers that support `Sec-Fetch-Dest` and for those that don't. + * + * @returns boolean indicating if the request is eligible for handshake + */ + isRequestEligibleForHandshake(): boolean { + const { accept, secFetchDest } = this.authenticateContext; + + // NOTE: we could also check sec-fetch-mode === navigate here, but according to the spec, sec-fetch-dest: document should indicate that the request is the data of a user navigation. + // Also, we check for 'iframe' because it's the value set when a doc request is made by an iframe. + if (secFetchDest === 'document' || secFetchDest === 'iframe') { + return true; + } + + if (!secFetchDest && accept?.startsWith('text/html')) { + return true; + } + + return false; + } + + /** + * Builds the redirect headers for a handshake request + * @param reason - The reason for the handshake (e.g. 'session-token-expired') + * @returns Headers object containing the Location header for redirect + * @throws Error if clerkUrl is missing in authenticateContext + */ + buildRedirectToHandshake(reason: string): Headers { + if (!this.authenticateContext?.clerkUrl) { + throw new Error('Missing clerkUrl in authenticateContext'); + } + + const redirectUrl = this.removeDevBrowserFromURL(this.authenticateContext.clerkUrl); + const frontendApiNoProtocol = this.authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); + + const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); + url.searchParams.append('redirect_url', redirectUrl?.href || ''); + url.searchParams.append( + constants.QueryParameters.SuffixedCookies, + this.authenticateContext.usesSuffixedCookies().toString(), + ); + url.searchParams.append(constants.QueryParameters.HandshakeReason, reason); + + if (this.authenticateContext.instanceType === 'development' && this.authenticateContext.devBrowserToken) { + url.searchParams.append(constants.QueryParameters.DevBrowser, this.authenticateContext.devBrowserToken); + } + + const toActivate = this.getOrganizationSyncTarget(this.authenticateContext.clerkUrl, this.organizationMatcher); + if (toActivate) { + const params = this.getOrganizationSyncQueryParams(toActivate); + params.forEach((value, key) => { + url.searchParams.append(key, value); + }); + } + + return new Headers({ [constants.Headers.Location]: url.href }); + } + + /** + * Resolves a handshake request by verifying the handshake token and setting appropriate cookies + * @returns Promise resolving to either a SignedInState or SignedOutState + * @throws Error if handshake verification fails or if there are issues with the session token + */ + async resolveHandshake(): Promise { + const headers = new Headers({ + 'Access-Control-Allow-Origin': 'null', + 'Access-Control-Allow-Credentials': 'true', + }); + + const cookiesToSet: string[] = []; + + if (this.authenticateContext.handshakeNonce) { + // TODO: implement handshake nonce handling, fetch handshake payload with nonce + console.warn('Clerk: Handshake nonce is not implemented yet.'); + } + if (this.authenticateContext.handshakeToken) { + const handshakePayload = await verifyHandshakeToken( + this.authenticateContext.handshakeToken, + this.authenticateContext, + ); + cookiesToSet.push(...handshakePayload.handshake); + } + + let sessionToken = ''; + cookiesToSet.forEach((x: string) => { + headers.append('Set-Cookie', x); + if (getCookieName(x).startsWith(constants.Cookies.Session)) { + sessionToken = getCookieValue(x); + } + }); + + if (this.authenticateContext.instanceType === 'development') { + const newUrl = new URL(this.authenticateContext.clerkUrl); + newUrl.searchParams.delete(constants.QueryParameters.Handshake); + newUrl.searchParams.delete(constants.QueryParameters.HandshakeHelp); + headers.append(constants.Headers.Location, newUrl.toString()); + headers.set(constants.Headers.CacheControl, 'no-store'); + } + + if (sessionToken === '') { + return signedOut(this.authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); + } + + const { data, errors: [error] = [] } = await verifyToken(sessionToken, this.authenticateContext); + if (data) { + return signedIn(this.authenticateContext, data, headers, sessionToken); + } + + if ( + this.authenticateContext.instanceType === 'development' && + (error?.reason === TokenVerificationErrorReason.TokenExpired || + error?.reason === TokenVerificationErrorReason.TokenNotActiveYet || + error?.reason === TokenVerificationErrorReason.TokenIatInTheFuture) + ) { + // Create a new error object with the same properties + const developmentError = new TokenVerificationError({ + action: error.action, + message: error.message, + reason: error.reason, + }); + // Set the tokenCarrier after construction + developmentError.tokenCarrier = 'cookie'; + + console.error( + `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. + +To resolve this issue, make sure your system's clock is set to the correct time (e.g. turn off and on automatic time synchronization). + +--- + +${developmentError.getFullMessage()}`, + ); + + const { data: retryResult, errors: [retryError] = [] } = await verifyToken(sessionToken, { + ...this.authenticateContext, + clockSkewInMs: 86_400_000, + }); + if (retryResult) { + return signedIn(this.authenticateContext, retryResult, headers, sessionToken); + } + + throw new Error(retryError?.message || 'Clerk: Handshake retry failed.'); + } + + throw new Error(error?.message || 'Clerk: Handshake failed.'); + } + + /** + * Handles handshake token verification errors in development mode + * @param error - The TokenVerificationError that occurred + * @throws Error with a descriptive message about the verification failure + */ + handleTokenVerificationErrorInDevelopment(error: TokenVerificationError): void { + // In development, the handshake token is being transferred in the URL as a query parameter, so there is no + // possibility of collision with a handshake token of another app running on the same local domain + // (etc one app on localhost:3000 and one on localhost:3001). + // Therefore, if the handshake token is invalid, it is likely that the user has switched Clerk keys locally. + // We make sure to throw a descriptive error message and then stop the handshake flow in every case, + // to avoid the possibility of an infinite loop. + if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) { + const msg = `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`; + throw new Error(msg); + } + throw new Error(`Clerk: Handshake token verification failed: ${error.getFullMessage()}.`); + } + + /** + * Checks if a redirect loop is detected and sets headers to track redirect count + * @param headers - The Headers object to modify + * @returns boolean indicating if a redirect loop was detected (true) or if the request can proceed (false) + */ + checkAndTrackRedirectLoop(headers: Headers): boolean { + if (this.redirectLoopCounter === 3) { + return true; + } + + const newCounterValue = this.redirectLoopCounter + 1; + const cookieName = constants.Cookies.RedirectCount; + headers.append('Set-Cookie', `${cookieName}=${newCounterValue}; SameSite=Lax; HttpOnly; Max-Age=3`); + return false; + } + + private removeDevBrowserFromURL(url: URL): URL { + const updatedURL = new URL(url); + updatedURL.searchParams.delete(constants.QueryParameters.DevBrowser); + updatedURL.searchParams.delete(constants.QueryParameters.LegacyDevBrowser); + return updatedURL; + } + + private getOrganizationSyncTarget(url: URL, matchers: OrganizationMatcher): OrganizationSyncTarget | null { + return matchers.findTarget(url); + } + + private getOrganizationSyncQueryParams(toActivate: OrganizationSyncTarget): Map { + const ret = new Map(); + if (toActivate.type === 'personalAccount') { + ret.set('organization_id', ''); + } + if (toActivate.type === 'organization') { + if (toActivate.organizationId) { + ret.set('organization_id', toActivate.organizationId); + } + if (toActivate.organizationSlug) { + ret.set('organization_id', toActivate.organizationSlug); + } + } + return ret; + } +} diff --git a/packages/backend/src/tokens/organizationMatcher.ts b/packages/backend/src/tokens/organizationMatcher.ts new file mode 100644 index 00000000000..820c8dcc0bf --- /dev/null +++ b/packages/backend/src/tokens/organizationMatcher.ts @@ -0,0 +1,60 @@ +import type { MatchFunction } from '@clerk/shared/pathToRegexp'; +import { match } from '@clerk/shared/pathToRegexp'; + +import type { OrganizationSyncOptions, OrganizationSyncTarget } from './types'; + +export class OrganizationMatcher { + private readonly organizationPattern: MatchFunction | null; + private readonly personalAccountPattern: MatchFunction | null; + + constructor(options?: OrganizationSyncOptions) { + this.organizationPattern = this.createMatcher(options?.organizationPatterns); + this.personalAccountPattern = this.createMatcher(options?.personalAccountPatterns); + } + + private createMatcher(pattern?: string[]): MatchFunction | null { + if (!pattern) return null; + try { + return match(pattern); + } catch (e) { + throw new Error(`Invalid pattern "${pattern}": ${e}`); + } + } + + findTarget(url: URL): OrganizationSyncTarget | null { + const orgTarget = this.findOrganizationTarget(url); + if (orgTarget) return orgTarget; + + return this.findPersonalAccountTarget(url); + } + + private findOrganizationTarget(url: URL): OrganizationSyncTarget | null { + if (!this.organizationPattern) return null; + + try { + const result = this.organizationPattern(url.pathname); + if (!result || !('params' in result)) return null; + + const params = result.params as { id?: string; slug?: string }; + if (params.id) return { type: 'organization', organizationId: params.id }; + if (params.slug) return { type: 'organization', organizationSlug: params.slug }; + + return null; + } catch (e) { + console.error('Failed to match organization pattern:', e); + return null; + } + } + + private findPersonalAccountTarget(url: URL): OrganizationSyncTarget | null { + if (!this.personalAccountPattern) return null; + + try { + const result = this.personalAccountPattern(url.pathname); + return result ? { type: 'personalAccount' } : null; + } catch (e) { + console.error('Failed to match personal account pattern:', e); + return null; + } + } +} diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 9d714029618..882aae8158a 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,5 +1,3 @@ -import type { Match, MatchFunction } from '@clerk/shared/pathToRegexp'; -import { match } from '@clerk/shared/pathToRegexp'; import type { JwtPayload } from '@clerk/types'; import { constants } from '../constants'; @@ -15,8 +13,9 @@ import type { HandshakeState, RequestState, SignedInState, SignedOutState } from 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 { HandshakeService } from './handshake'; +import { OrganizationMatcher } from './organizationMatcher'; +import type { AuthenticateRequestOptions } from './types'; import { verifyToken } from './verify'; export const RefreshTokenErrorReason = { @@ -58,26 +57,6 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { } } -/** - * Currently, a request is only eligible for a handshake if we can say it's *probably* a request for a document, not a fetch or some other exotic request. - * This heuristic should give us a reliable enough signal for browsers that support `Sec-Fetch-Dest` and for those that don't. - */ -function isRequestEligibleForHandshake(authenticateContext: { secFetchDest?: string; accept?: string }) { - const { accept, secFetchDest } = authenticateContext; - - // NOTE: we could also check sec-fetch-mode === navigate here, but according to the spec, sec-fetch-dest: document should indicate that the request is the data of a user navigation. - // Also, we check for 'iframe' because it's the value set when a doc request is made by an iframe. - if (secFetchDest === 'document' || secFetchDest === 'iframe') { - return true; - } - - if (!secFetchDest && accept?.startsWith('text/html')) { - return true; - } - - return false; -} - function isRequestEligibleForRefresh( err: TokenVerificationError, authenticateContext: { refreshTokenInCookie?: string }, @@ -105,118 +84,12 @@ export async function authenticateRequest( assertProxyUrlOrDomain(authenticateContext.proxyUrl || authenticateContext.domain); } - // NOTE(izaak): compute regex matchers early for efficiency - they can be used multiple times. - const organizationSyncTargetMatchers = computeOrganizationSyncTargetMatchers(options.organizationSyncOptions); - - function removeDevBrowserFromURL(url: URL) { - const updatedURL = new URL(url); - - updatedURL.searchParams.delete(constants.QueryParameters.DevBrowser); - // Remove legacy dev browser query param key to support local app with v5 using AP with v4 - updatedURL.searchParams.delete(constants.QueryParameters.LegacyDevBrowser); - - return updatedURL; - } - - function buildRedirectToHandshake({ handshakeReason }: { handshakeReason: string }) { - const redirectUrl = removeDevBrowserFromURL(authenticateContext.clerkUrl); - const frontendApiNoProtocol = authenticateContext.frontendApi.replace(/http(s)?:\/\//, ''); - - const url = new URL(`https://${frontendApiNoProtocol}/v1/client/handshake`); - url.searchParams.append('redirect_url', redirectUrl?.href || ''); - url.searchParams.append( - constants.QueryParameters.SuffixedCookies, - authenticateContext.usesSuffixedCookies().toString(), - ); - url.searchParams.append(constants.QueryParameters.HandshakeReason, handshakeReason); - - if (authenticateContext.instanceType === 'development' && authenticateContext.devBrowserToken) { - url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken); - } - - const toActivate = getOrganizationSyncTarget( - authenticateContext.clerkUrl, - options.organizationSyncOptions, - organizationSyncTargetMatchers, - ); - if (toActivate) { - const params = getOrganizationSyncQueryParams(toActivate); - - params.forEach((value, key) => { - url.searchParams.append(key, value); - }); - } - - return new Headers({ [constants.Headers.Location]: url.href }); - } - - async function resolveHandshake() { - const headers = new Headers({ - 'Access-Control-Allow-Origin': 'null', - 'Access-Control-Allow-Credentials': 'true', - }); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const handshakePayload = await verifyHandshakeToken(authenticateContext.handshakeToken!, authenticateContext); - const cookiesToSet = handshakePayload.handshake; - - let sessionToken = ''; - cookiesToSet.forEach((x: string) => { - headers.append('Set-Cookie', x); - if (getCookieName(x).startsWith(constants.Cookies.Session)) { - sessionToken = getCookieValue(x); - } - }); - - if (authenticateContext.instanceType === 'development') { - const newUrl = new URL(authenticateContext.clerkUrl); - newUrl.searchParams.delete(constants.QueryParameters.Handshake); - newUrl.searchParams.delete(constants.QueryParameters.HandshakeHelp); - headers.append(constants.Headers.Location, newUrl.toString()); - headers.set(constants.Headers.CacheControl, 'no-store'); - } - - if (sessionToken === '') { - return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); - } - - const { data, errors: [error] = [] } = await verifyToken(sessionToken, authenticateContext); - if (data) { - return signedIn(authenticateContext, data, headers, sessionToken); - } - - if ( - authenticateContext.instanceType === 'development' && - (error?.reason === TokenVerificationErrorReason.TokenExpired || - error?.reason === TokenVerificationErrorReason.TokenNotActiveYet || - error?.reason === TokenVerificationErrorReason.TokenIatInTheFuture) - ) { - error.tokenCarrier = 'cookie'; - // This probably means we're dealing with clock skew - console.error( - `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. - -To resolve this issue, make sure your system's clock is set to the correct time (e.g. turn off and on automatic time synchronization). - ---- - -${error.getFullMessage()}`, - ); - - // Retry with a generous clock skew allowance (1 day) - const { data: retryResult, errors: [retryError] = [] } = await verifyToken(sessionToken, { - ...authenticateContext, - clockSkewInMs: 86_400_000, - }); - if (retryResult) { - return signedIn(authenticateContext, retryResult, headers, sessionToken); - } - - throw new Error(retryError?.message || 'Clerk: Handshake retry failed.'); - } - - throw new Error(error?.message || 'Clerk: Handshake failed.'); - } + const organizationMatcher = new OrganizationMatcher(options.organizationSyncOptions); + const handshakeService = new HandshakeService( + authenticateContext, + { organizationSyncOptions: options.organizationSyncOptions }, + organizationMatcher, + ); async function refreshToken( authenticateContext: AuthenticateContext, @@ -354,31 +227,31 @@ ${error.getFullMessage()}`, message: string, headers?: Headers, ): SignedInState | SignedOutState | HandshakeState { - if (isRequestEligibleForHandshake(authenticateContext)) { - // Right now the only usage of passing in different headers is for multi-domain sync, which redirects somewhere else. - // In the future if we want to decorate the handshake redirect with additional headers per call we need to tweak this logic. - const handshakeHeaders = headers ?? buildRedirectToHandshake({ handshakeReason: reason }); - - // Chrome aggressively caches inactive tabs. If we don't set the header here, - // all 307 redirects will be cached and the handshake will end up in an infinite loop. - if (handshakeHeaders.get(constants.Headers.Location)) { - handshakeHeaders.set(constants.Headers.CacheControl, 'no-store'); - } + if (!handshakeService.isRequestEligibleForHandshake()) { + return signedOut(authenticateContext, reason, message); + } - // Introduce the mechanism to protect for infinite handshake redirect loops - // using a cookie and returning true if it's infinite redirect loop or false if we can - // proceed with triggering handshake. - const isRedirectLoop = setHandshakeInfiniteRedirectionLoopHeaders(handshakeHeaders); - 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); - } + // Right now the only usage of passing in different headers is for multi-domain sync, which redirects somewhere else. + // In the future if we want to decorate the handshake redirect with additional headers per call we need to tweak this logic. + const handshakeHeaders = headers ?? handshakeService.buildRedirectToHandshake(reason); + + // Chrome aggressively caches inactive tabs. If we don't set the header here, + // all 307 redirects will be cached and the handshake will end up in an infinite loop. + if (handshakeHeaders.get(constants.Headers.Location)) { + handshakeHeaders.set(constants.Headers.CacheControl, 'no-store'); + } - return handshake(authenticateContext, reason, message, handshakeHeaders); + // Introduce the mechanism to protect for infinite handshake redirect loops + // using a cookie and returning true if it's infinite redirect loop or false if we can + // proceed with triggering handshake. + const isRedirectLoop = handshakeService.checkAndTrackRedirectLoop(handshakeHeaders); + 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(authenticateContext, reason, message); + return handshake(authenticateContext, reason, message, handshakeHeaders); } /** @@ -394,11 +267,7 @@ ${error.getFullMessage()}`, authenticateContext: AuthenticateContext, auth: SignedInAuthObject, ): HandshakeState | SignedOutState | null { - const organizationSyncTarget = getOrganizationSyncTarget( - authenticateContext.clerkUrl, - options.organizationSyncOptions, - organizationSyncTargetMatchers, - ); + const organizationSyncTarget = organizationMatcher.findTarget(authenticateContext.clerkUrl); if (!organizationSyncTarget) { return null; } @@ -459,34 +328,6 @@ ${error.getFullMessage()}`, } } - // We want to prevent infinite handshake redirection loops. - // We incrementally set a `__clerk_redirection_loop` cookie, and when it loops 3 times, we throw an error. - // We also utilize the `referer` header to skip the prefetch requests. - function setHandshakeInfiniteRedirectionLoopHeaders(headers: Headers): boolean { - if (authenticateContext.handshakeRedirectLoopCounter === 3) { - return true; - } - - const newCounterValue = authenticateContext.handshakeRedirectLoopCounter + 1; - const cookieName = constants.Cookies.RedirectCount; - headers.append('Set-Cookie', `${cookieName}=${newCounterValue}; SameSite=Lax; HttpOnly; Max-Age=3`); - return false; - } - - function handleHandshakeTokenVerificationErrorInDevelopment(error: TokenVerificationError) { - // In development, the handshake token is being transferred in the URL as a query parameter, so there is no - // possibility of collision with a handshake token of another app running on the same local domain - // (etc one app on localhost:3000 and one on localhost:3001). - // Therefore, if the handshake token is invalid, it is likely that the user has switched Clerk keys locally. - // We make sure to throw a descriptive error message and then stop the handshake flow in every case, - // to avoid the possibility of an infinite loop. - if (error.reason === TokenVerificationErrorReason.TokenInvalidSignature) { - const msg = `Clerk: Handshake token verification failed due to an invalid signature. If you have switched Clerk keys locally, clear your cookies and try again.`; - throw new Error(msg); - } - throw new Error(`Clerk: Handshake token verification failed: ${error.getFullMessage()}.`); - } - async function authenticateRequestWithTokenInCookie() { const hasActiveClient = authenticateContext.clientUat; const hasSessionToken = !!authenticateContext.sessionTokenInCookie; @@ -497,7 +338,7 @@ ${error.getFullMessage()}`, */ if (authenticateContext.handshakeToken) { try { - return await resolveHandshake(); + return await handshakeService.resolveHandshake(); } catch (error) { // In production, the handshake token is being transferred as a cookie, so there is a possibility of collision // with a handshake token of another app running on the same etld+1 domain. @@ -509,9 +350,9 @@ ${error.getFullMessage()}`, // We need to make sure, however, that we don't allow the flow to continue indefinitely, so we throw an error after X // retries to avoid an infinite loop. An infinite loop can happen if the customer switched Clerk keys for their prod app. - // Check the handleHandshakeTokenVerificationErrorInDevelopment function for the development case. + // Check the handleTokenVerificationErrorInDevelopment method for the development case. if (error instanceof TokenVerificationError && authenticateContext.instanceType === 'development') { - handleHandshakeTokenVerificationErrorInDevelopment(error); + handshakeService.handleTokenVerificationErrorInDevelopment(error); } else { console.error('Clerk: unable to resolve handshake:', error); } @@ -706,132 +547,6 @@ export const debugRequestState = (params: RequestState) => { return { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain }; }; -type OrganizationSyncTargetMatchers = { - OrganizationMatcher: MatchFunction>> | null; - PersonalAccountMatcher: MatchFunction>> | null; -}; - -/** - * Computes regex-based matchers from the given organization sync options. - */ -export function computeOrganizationSyncTargetMatchers( - options: OrganizationSyncOptions | undefined, -): OrganizationSyncTargetMatchers { - let personalAccountMatcher: MatchFunction>> | null = null; - if (options?.personalAccountPatterns) { - try { - personalAccountMatcher = match(options.personalAccountPatterns); - } catch (e) { - // Likely to be encountered during development, so throwing the error is more prudent than logging - throw new Error(`Invalid personal account pattern "${options.personalAccountPatterns}": "${e}"`); - } - } - - let organizationMatcher: MatchFunction>> | null = null; - if (options?.organizationPatterns) { - try { - organizationMatcher = match(options.organizationPatterns); - } catch (e) { - // Likely to be encountered during development, so throwing the error is more prudent than logging - throw new Error(`Clerk: Invalid organization pattern "${options.organizationPatterns}": "${e}"`); - } - } - - return { - OrganizationMatcher: organizationMatcher, - PersonalAccountMatcher: personalAccountMatcher, - }; -} - -/** - * Determines if the given URL and settings indicate a desire to activate a specific - * organization or personal account. - * - * @param url - The URL of the original request. - * @param options - The organization sync options. - * @param matchers - The matchers for the organization and personal account patterns, as generated by `computeOrganizationSyncTargetMatchers`. - */ -export function getOrganizationSyncTarget( - url: URL, - options: OrganizationSyncOptions | undefined, - matchers: OrganizationSyncTargetMatchers, -): OrganizationSyncTarget | null { - if (!options) { - return null; - } - - // Check for organization activation - if (matchers.OrganizationMatcher) { - let orgResult: Match>>; - try { - orgResult = matchers.OrganizationMatcher(url.pathname); - } catch (e) { - // Intentionally not logging the path to avoid potentially leaking anything sensitive - console.error(`Clerk: Failed to apply organization pattern "${options.organizationPatterns}" to a path`, e); - return null; - } - - if (orgResult && 'params' in orgResult) { - const params = orgResult.params; - - if ('id' in params && typeof params.id === 'string') { - return { type: 'organization', organizationId: params.id }; - } - if ('slug' in params && typeof params.slug === 'string') { - return { type: 'organization', organizationSlug: params.slug }; - } - console.warn( - 'Clerk: Detected an organization pattern match, but no organization ID or slug was found in the URL. Does the pattern include `:id` or `:slug`?', - ); - } - } - - // Check for personal account activation - if (matchers.PersonalAccountMatcher) { - let personalResult: Match>>; - try { - personalResult = matchers.PersonalAccountMatcher(url.pathname); - } catch (e) { - // Intentionally not logging the path to avoid potentially leaking anything sensitive - console.error(`Failed to apply personal account pattern "${options.personalAccountPatterns}" to a path`, e); - return null; - } - - if (personalResult) { - return { type: 'personalAccount' }; - } - } - return null; -} - -/** - * Represents an organization or a personal account - e.g. an - * entity that can be activated by the handshake API. - */ -export type OrganizationSyncTarget = - | { type: 'personalAccount' } - | { type: 'organization'; organizationId?: string; organizationSlug?: string }; - -/** - * Generates the query parameters to activate an organization or personal account - * via the FAPI handshake api. - */ -function getOrganizationSyncQueryParams(toActivate: OrganizationSyncTarget): Map { - const ret = new Map(); - if (toActivate.type === 'personalAccount') { - ret.set('organization_id', ''); - } - if (toActivate.type === 'organization') { - if (toActivate.organizationId) { - ret.set('organization_id', toActivate.organizationId); - } - if (toActivate.organizationSlug) { - ret.set('organization_id', toActivate.organizationSlug); - } - } - return ret; -} - const convertTokenVerificationErrorReasonToAuthErrorReason = ({ tokenError, refreshError, diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index f96417c8182..ad282609dc6 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -1,3 +1,5 @@ +import type { MatchFunction } from '@clerk/shared/pathToRegexp'; + import type { ApiClient } from '../api'; import type { VerifyTokenOptions } from './verify'; @@ -116,3 +118,16 @@ export type OrganizationSyncOptions = { * ``` */ type Pattern = string; + +export type OrganizationSyncTargetMatchers = { + OrganizationMatcher: MatchFunction>> | null; + PersonalAccountMatcher: MatchFunction>> | null; +}; + +/** + * Represents an organization or a personal account - e.g. an + * entity that can be activated by the handshake API. + */ +export type OrganizationSyncTarget = + | { type: 'personalAccount' } + | { type: 'organization'; organizationId?: string; organizationSlug?: string };