diff --git a/.changeset/poppy-metadata-helper.md b/.changeset/poppy-metadata-helper.md new file mode 100644 index 00000000000..6a83035579b --- /dev/null +++ b/.changeset/poppy-metadata-helper.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add `clerkClient.__experimental_getPoppyMetadata()` to fetch instance POPPY metadata for a customer-hosted discovery route without sending credentials. Pass an absolute `proxyUrl` to fetch through a customer-hosted Frontend API proxy instead. diff --git a/packages/backend/src/__tests__/poppy.test.ts b/packages/backend/src/__tests__/poppy.test.ts new file mode 100644 index 00000000000..9467fa191fb --- /dev/null +++ b/packages/backend/src/__tests__/poppy.test.ts @@ -0,0 +1,312 @@ +import { http, HttpResponse } from 'msw'; +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { ClerkAPIResponseError } from '../errors'; +import { createClerkClient } from '../index'; +import { server } from '../mock-server'; +import { clerkFrontendApiProxy } from '../proxy'; +import { runtime } from '../runtime'; + +const metadata = { + protocol_version: '0.2', + organization: { name: 'C1', domain: 'c1.example' }, +}; +const publishableKey = (host: string, mode = 'test') => `pk_${mode}_${btoa(`${host}$`)}`; +const endpoint = 'https://example.clerk.accounts.dev/.well-known/poppy.json'; + +afterEach(() => vi.restoreAllMocks()); + +describe('clerkClient.__experimental_getPoppyMetadata', () => { + it('returns an unknown response type', () => { + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + expectTypeOf(client.__experimental_getPoppyMetadata).returns.toEqualTypeOf>(); + }); + + it.each([ + { name: 'development', host: 'example.clerk.accounts.dev', mode: 'test' }, + { name: 'production', host: 'clerk.c1.example', mode: 'live' }, + ])('fetches the $name instance without a secret key', async ({ host, mode }) => { + server.use(http.get(`https://${host}/.well-known/poppy.json`, () => HttpResponse.json(metadata))); + + const client = createClerkClient({ publishableKey: publishableKey(host, mode) }); + + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(metadata); + }); + + it('uses only the publishable key host and sends no credentials', async () => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + server.use( + http.get(endpoint, ({ request }) => { + expect(request.headers.get('accept')).toBe('application/json'); + expect(request.headers.get('authorization')).toBeNull(); + expect(request.headers.get('cookie')).toBeNull(); + expect(request.credentials).toBe('omit'); + expect(request.redirect).toBe('error'); + expect(request.cache).toBe('no-store'); + return HttpResponse.json(metadata); + }), + ); + const client = createClerkClient({ + publishableKey: publishableKey('example.clerk.accounts.dev'), + secretKey: 'sk_test_example', + apiUrl: 'https://backend.example', + proxyUrl: 'https://proxy.example', + domain: 'satellite.example', + isSatellite: true, + }); + + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(metadata); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(new URL(endpoint), { + headers: { Accept: 'application/json' }, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + }); + }); + + it('preserves future fields and fetches each document without a cache', async () => { + const futureMetadata = { + ...metadata, + protocol_version: '0.3', + organization: { ...metadata.organization, future_field: 'value' }, + future_capability: { nested_field: ['one', 'two'] }, + }; + let requests = 0; + server.use( + http.get(endpoint, () => { + requests++; + return HttpResponse.json(futureMetadata); + }), + ); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(futureMetadata); + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(futureMetadata); + expect(requests).toBe(2); + }); + + it('keeps independently configured clients separate', async () => { + server.use( + http.get(endpoint, () => HttpResponse.json(metadata)), + http.get('https://clerk.other.example/.well-known/poppy.json', () => + HttpResponse.json({ ...metadata, organization: { name: 'Other', domain: 'other.example' } }), + ), + ); + const first = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + const second = createClerkClient({ publishableKey: publishableKey('clerk.other.example', 'live') }); + + await expect(first.__experimental_getPoppyMetadata()).resolves.toEqual(metadata); + await expect(second.__experimental_getPoppyMetadata()).resolves.toMatchObject({ + organization: { domain: 'other.example' }, + }); + }); + + it.each([ + { proxyUrl: 'https://customer.example/__clerk', target: 'https://customer.example/__clerk/.well-known/poppy.json' }, + { + proxyUrl: 'https://customer.example/__clerk/', + target: 'https://customer.example/__clerk/.well-known/poppy.json', + }, + { + proxyUrl: 'https://customer.example/nested/__clerk///', + target: 'https://customer.example/nested/__clerk/.well-known/poppy.json', + }, + { + proxyUrl: new URL('https://customer.example/nested/__clerk/'), + target: 'https://customer.example/nested/__clerk/.well-known/poppy.json', + }, + { + proxyUrl: new URL('/__clerk', 'https://customer.example/nested/route'), + target: 'https://customer.example/__clerk/.well-known/poppy.json', + }, + { proxyUrl: 'http://localhost:3000/__clerk', target: 'http://localhost:3000/__clerk/.well-known/poppy.json' }, + ])('fetches through explicit proxy $proxyUrl without a publishable key', async ({ proxyUrl, target }) => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + const originalUrl = proxyUrl.toString(); + server.use(http.get(target, () => HttpResponse.json(metadata))); + const client = createClerkClient({}); + + await expect(client.__experimental_getPoppyMetadata({ proxyUrl })).resolves.toEqual(metadata); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(new URL(target), { + headers: { Accept: 'application/json' }, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + }); + expect(proxyUrl.toString()).toBe(originalUrl); + }); + + it.each([ + '/__clerk', + '//customer.example/__clerk', + 'file:///__clerk', + 'ftp://customer.example/__clerk', + 'https://user:password@customer.example/__clerk', + 'https://customer.example/__clerk?query=value', + 'https://customer.example/__clerk#fragment', + ])('rejects invalid proxy %s before fetching', async proxyUrl => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata({ proxyUrl })).rejects.toThrow(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([ + { name: 'HTTP 404', response: () => new HttpResponse(null, { status: 404 }) }, + { name: 'network failure', response: () => HttpResponse.error() }, + { name: 'redirect', response: () => HttpResponse.redirect(endpoint) }, + ])('does not fall back to direct FAPI after proxy $name', async ({ response }) => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + server.use(http.get('https://customer.example/__clerk/.well-known/poppy.json', response)); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect( + client.__experimental_getPoppyMetadata({ proxyUrl: 'https://customer.example/__clerk' }), + ).rejects.toThrow(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('does not retain an explicit proxy for later direct calls', async () => { + const directMetadata = { source: 'direct' }; + server.use( + http.get('https://customer.example/__clerk/.well-known/poppy.json', () => HttpResponse.json(metadata)), + http.get(endpoint, () => HttpResponse.json(directMetadata)), + ); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect( + client.__experimental_getPoppyMetadata({ proxyUrl: 'https://customer.example/__clerk' }), + ).resolves.toEqual(metadata); + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(directMetadata); + }); + + it('forwards the well-known path through the existing customer proxy without sending credentials to it', async () => { + let proxyRequests = 0; + let upstreamRequests = 0; + server.use( + http.get('https://customer.example/__clerk/.well-known/poppy.json', async ({ request }) => { + proxyRequests++; + expect(request.headers.get('accept')).toBe('application/json'); + expect(request.headers.get('authorization')).toBeNull(); + expect(request.headers.get('cookie')).toBeNull(); + expect(request.headers.get('clerk-secret-key')).toBeNull(); + expect(request.headers.get('clerk-proxy-url')).toBeNull(); + return clerkFrontendApiProxy(request, { + publishableKey: publishableKey('example.clerk.accounts.dev'), + secretKey: 'sk_test_proxy', + fapiUrl: 'https://frontend-api.clerk.example', + }); + }), + http.get('https://frontend-api.clerk.example/.well-known/poppy.json', ({ request }) => { + upstreamRequests++; + expect(request.headers.get('clerk-secret-key')).toBe('sk_test_proxy'); + expect(request.headers.get('clerk-proxy-url')).toBe('https://customer.example/__clerk'); + expect(request.headers.get('authorization')).toBeNull(); + expect(request.headers.get('cookie')).toBeNull(); + return HttpResponse.json(metadata); + }), + ); + const client = createClerkClient({ + secretKey: 'sk_test_helper', + proxyUrl: 'https://unused.example', + apiUrl: 'https://backend.example', + }); + + await expect( + client.__experimental_getPoppyMetadata({ proxyUrl: 'https://customer.example/__clerk' }), + ).resolves.toEqual(metadata); + expect(proxyRequests).toBe(1); + expect(upstreamRequests).toBe(1); + }); + + it.each([ + { name: 'missing key', key: undefined }, + { name: 'invalid key', key: 'invalid' }, + ...[ + 'user@example.com', + 'example.com/path', + 'example.com?query', + 'example.com#fragment', + 'example.com:443', + 'https://example.com', + 'example.com\\path', + 'example.com\n', + ].map(host => ({ name: host, key: publishableKey(host) })), + ])('rejects $name before fetching', async ({ key }) => { + const fetchSpy = vi.spyOn(runtime, 'fetch'); + const client = createClerkClient({ publishableKey: key }); + + await expect(client.__experimental_getPoppyMetadata()).rejects.toThrow(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([404, 500])('preserves HTTP %s and standard Clerk errors without retrying', async status => { + let requests = 0; + server.use( + http.get(endpoint, () => { + requests++; + return HttpResponse.json( + { + errors: [{ code: 'not_available', message: 'Not available', long_message: 'Try later' }], + clerk_trace_id: 'trace', + }, + { status }, + ); + }), + ); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + const error = await client.__experimental_getPoppyMetadata().catch(error => error); + expect(error).toBeInstanceOf(ClerkAPIResponseError); + expect(error).toMatchObject({ + status, + clerkTraceId: 'trace', + errors: [{ code: 'not_available', message: 'Not available', longMessage: 'Try later' }], + }); + expect(requests).toBe(1); + }); + + it.each([ + { name: 'HTML', response: () => new HttpResponse('Error', { status: 500 }) }, + { + name: 'malformed error data', + response: () => HttpResponse.json({ errors: [null, {}, 'error'] }, { status: 500 }), + }, + ])('preserves status for $name error responses', async ({ response }) => { + server.use(http.get(endpoint, response)); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).rejects.toMatchObject({ status: 500, errors: [] }); + }); + + it.each([ + null, + [], + {}, + 'metadata', + 42, + true, + { ...metadata, protocol_version: 2 }, + { ...metadata, organization: null }, + { ...metadata, organization: { name: 'C1' } }, + { ...metadata, organization: { name: 1, domain: 'c1.example' } }, + ])('returns JSON %j unchanged', async body => { + server.use(http.get(endpoint, () => HttpResponse.json(body))); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).resolves.toEqual(body); + }); + + it.each([ + { name: 'invalid JSON', response: () => new HttpResponse('not json') }, + { name: 'network failure', response: () => HttpResponse.error() }, + { name: 'redirect', response: () => HttpResponse.redirect('https://other.example/poppy.json') }, + ])('rejects $name', async ({ response }) => { + server.use(http.get(endpoint, response)); + const client = createClerkClient({ publishableKey: publishableKey('example.clerk.accounts.dev') }); + + await expect(client.__experimental_getPoppyMetadata()).rejects.toThrow(); + }); +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 9c77851ce76..ffedd438773 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -5,6 +5,7 @@ import type { SDKMetadata } from '@clerk/shared/types'; import type { ApiClient, CreateBackendApiOptions } from './api'; import { createBackendApiClient } from './api'; import { withLegacyReturn } from './jwt/legacyReturn'; +import { createPoppyClient } from './poppy'; import type { CreateAuthenticateRequestOptions } from './tokens/factory'; import { createAuthenticateRequest } from './tokens/factory'; import { verifyToken as _verifyToken } from './tokens/verify'; @@ -24,6 +25,7 @@ export type ClerkOptions = Omit & ReturnType; export function createClerkClient(options: ClerkOptions): ClerkClient { @@ -41,6 +43,7 @@ export function createClerkClient(options: ClerkOptions): ClerkClient { return { ...apiClient, ...requestState, + ...createPoppyClient(opts.publishableKey), telemetry, }; } diff --git a/packages/backend/src/poppy.ts b/packages/backend/src/poppy.ts new file mode 100644 index 00000000000..bd7f329ddc9 --- /dev/null +++ b/packages/backend/src/poppy.ts @@ -0,0 +1,90 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { parsePublishableKey } from '@clerk/shared/keys'; + +import { stripTrailingSlashes } from './proxy'; +import { runtime } from './runtime'; + +export function createPoppyClient(publishableKey: string | undefined) { + return { + /** + * Fetches public POPPY metadata for the configured instance without sending credentials. + * @experimental This API is subject to change. + * @param options - Optional public proxy configuration. + * @param options.proxyUrl - Absolute HTTP(S) proxy URL, including its path prefix. Omit to use the instance directly. + * @returns The metadata document, unchanged, for a customer-hosted discovery route. + * @example + * ### Return metadata from a server route + * ```ts + * return Response.json(await clerkClient.__experimental_getPoppyMetadata()); + * ``` + * @example + * ### Fetch through a customer-hosted proxy + * ```ts + * return Response.json(await clerkClient.__experimental_getPoppyMetadata({ + * proxyUrl: new URL('/__clerk', request.url), + * })); + * ``` + */ + async __experimental_getPoppyMetadata(options?: { proxyUrl?: string | URL }): Promise { + let url: URL; + if (options?.proxyUrl !== undefined) { + url = new URL(options.proxyUrl); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error('Proxy URL must use HTTP(S) without credentials, a query string, or a fragment.'); + } + url.pathname = `${stripTrailingSlashes(url.pathname)}/.well-known/poppy.json`; + } else { + const { frontendApi } = parsePublishableKey(publishableKey, { fatal: true }); + url = new URL(`https://${frontendApi}`); + if ( + url.host !== frontendApi || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash || + url.port + ) { + throw new Error('Publishable key has an invalid frontend API hostname.'); + } + url.pathname = '/.well-known/poppy.json'; + } + + const response = await runtime.fetch(url, { + headers: { Accept: 'application/json' }, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + }); + + if (!response.ok) { + const body: unknown = await response.json().catch(() => null); + const errors = isRecord(body) && Array.isArray(body.errors) ? body.errors.filter(isApiError) : []; + throw new ClerkAPIResponseError(response.statusText, { + status: response.status, + data: errors, + clerkTraceId: + isRecord(body) && typeof body.clerk_trace_id === 'string' + ? body.clerk_trace_id + : response.headers.get('cf-ray') || undefined, + }); + } + + return response.json(); + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isApiError(value: unknown): value is { code: string; message: string } { + return isRecord(value) && typeof value.code === 'string' && typeof value.message === 'string'; +}