Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poppy-metadata-helper.md
Original file line number Diff line number Diff line change
@@ -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.
312 changes: 312 additions & 0 deletions packages/backend/src/__tests__/poppy.test.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<unknown>>();
});

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('<html>Error</html>', { 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();
});
});
3 changes: 3 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +25,7 @@ export type ClerkOptions = Omit<CreateBackendApiOptions, 'skipApiVersionInUrl' |
export type ClerkClient = {
telemetry: TelemetryCollector;
} & ApiClient &
ReturnType<typeof createPoppyClient> &
ReturnType<typeof createAuthenticateRequest>;

export function createClerkClient(options: ClerkOptions): ClerkClient {
Expand All @@ -41,6 +43,7 @@ export function createClerkClient(options: ClerkOptions): ClerkClient {
return {
...apiClient,
...requestState,
...createPoppyClient(opts.publishableKey),
telemetry,
};
}
Expand Down
Loading
Loading