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
12 changes: 12 additions & 0 deletions .changeset/poppy-browser-session-v04.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@clerk/backend": minor
"@clerk/nextjs": minor
---

Add Poppy 0.4 website browser Sessions. Enable the `__experimental_poppy` option on `clerkMiddleware()`, mount `PoppyCallbackPage`, and read authenticated agent attribution through `auth().agent`. The same option serves Poppy and OAuth protected resource discovery from the Backend API artifact. Signed-in connections use the normal Clerk ticket sign-in and Session activation flow.

The Backend SDK checks current OAuth authority for Poppy browser credentials and Poppy-derived Clerk Session JWTs. Sign-out or revocation ends account access and removes the agent from the auth object.

The low-level browser Session and metadata helpers are available through `clerkClient.__experimental_poppy` while the API is experimental.

The server auth object exposes `agent` only for an authenticated Clerk Session with agent attribution, using the existing `session.agent` shape. For a verified Poppy account session, `agent` also carries `__experimental_poppyClientId`, `__experimental_poppyUserId`, and `__experimental_poppySessionId`. These Clerk-defined experimental fields describe Poppy protocol identity and may change while Poppy is draft. They are not top-level auth fields. Signed-out protocol identity is not exposed through the auth object and does not populate `agent`, including beside an independent human login.
2 changes: 2 additions & 0 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ describe('public exports', () => {
expect(Object.keys(publicExports).sort()).toMatchInlineSnapshot(`
[
"createClerkClient",
"createPoppyBrowserSessionHandler",
"poppyCookies",
"verifyToken",
]
`);
Expand Down
170 changes: 170 additions & 0 deletions packages/backend/src/api/__tests__/PoppyApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { buildPublishableKey } from '@clerk/shared/keys';
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server } from '../../mock-server';
import { createPoppyBrowserSessionHandler } from '../../poppyBrowserSession';
import { createBackendApiClient } from '../factory';

describe('PoppyAPI', () => {
const endpoint = 'https://company.example/poppy/browser-session';
const frontendApi = 'https://clerk.company.example';
const publishableKey = buildPublishableKey('clerk.company.example');
const identity = { session_id: 'protocol-session', client_id: 'https://agent.example', user_id: 'pseudonym' };

it('gets discovery from the authenticated Backend API artifact without changing the document', async () => {
const metadata = {
protocol_version: '0.4',
organization: { name: 'Company', domain: 'company.example' },
auth: { issuer: frontendApi, scopes: { 'poppy:browser-sign-in': 'Full website access' } },
extension: { unchanged: true },
};
server.use(
http.get('https://api.clerk.test/v1/artifacts/well-known-poppy', ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer sk_live_example');
expect(new URL(request.url).search).toBe('');
return HttpResponse.json({ object: 'artifact', data: metadata });
}),
);
const { __experimental_poppy: poppy } = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
publishableKey,
secretKey: 'sk_live_example',
});
await expect(poppy.getMetadata()).resolves.toEqual(metadata);
});

it.each([401, 404, 503])('propagates Backend API artifact status %s', async status => {
server.use(
http.get('https://api.clerk.test/v1/artifacts/well-known-poppy', () =>
HttpResponse.json(
{
errors: [
{ code: 'artifact_unavailable', message: 'Artifact unavailable', long_message: 'Artifact unavailable' },
],
},
{ status },
),
),
);
const { __experimental_poppy: poppy } = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
publishableKey,
secretKey: 'sk_live_example',
});
await expect(poppy.getMetadata()).rejects.toMatchObject({ status });
});

it.each([
{ method: 'GET', suffix: '', body: undefined, status: 200, responseBody: '<form>Connect</form>' },
{ method: 'POST', suffix: '', body: 'code=browser-code', status: 303, responseBody: '' },
{
method: 'POST',
suffix: '/authorize',
body: '{"code":"browser-code"}',
status: 400,
responseBody: '{"error":"invalid_session"}',
},
{
method: 'POST',
suffix: '/authorize',
body: '{"code":"browser-code"}',
status: 429,
responseBody: '{"error":"rate_limited"}',
},
{ method: 'POST', suffix: '/authorize', body: 'malformed-json', status: 503, responseBody: 'Service unavailable' },
])(
'forwards $method $suffix and preserves status $status, headers and body',
async ({ method, suffix, body, status, responseBody }) => {
server.use(
http.all(frontendApi + '/v1/poppy/browser_sessions' + suffix, async ({ request }) => {
expect(request.method).toBe(method);
expect(await request.text()).toBe(body ?? '');
expect(request.headers.get('Clerk-Poppy-Endpoint')).toBe(endpoint);
expect(request.headers.get('Clerk-Poppy-Redirect')).toBe('https://company.example/cart');
expect(Object.fromEntries(request.headers)).toMatchObject({ 'poppy-version': '0.4' });
expect(request.headers.get('Poppy-Session')).toBe('proof');
expect(request.headers.get('Cookie')).toContain('__Host-poppy_join=browser-secret');
expect(request.headers.get('Authorization')).toBe('Bearer account-token');
expect(request.headers.has('Clerk-Secret-Key')).toBe(false);
return new HttpResponse(responseBody, {
status,
headers: {
Location: endpoint + '/callback',
'Set-Cookie': '__Host-poppy_ticket=ticket; Secure; HttpOnly; Path=/',
'Retry-After': '30',
'WWW-Authenticate': 'Bearer error="invalid_token"',
'Cache-Control': 'no-store',
},
});
}),
);
const handler = createPoppyBrowserSessionHandler({
clerkClient: createBackendApiClient({ publishableKey, secretKey: 'sk_live_example' }),
browserSessionEndpoint: endpoint,
redirectTo: '/cart',
});
const incoming = new Request(endpoint + suffix, {
method,
headers: {
'Poppy-Version': '0.4',
'Poppy-Session': 'proof',
Cookie: '__Host-poppy_join=browser-secret',
Authorization: 'Bearer account-token',
'Clerk-Poppy-Endpoint': 'https://untrusted.example/join',
},
body,
});
expect(incoming.headers.get('Poppy-Version')).toBe('0.4');
const response = await handler(incoming);
expect(response.status).toBe(status);
expect(await response.text()).toBe(responseBody);
expect(response.headers.get('location')).toBe(endpoint + '/callback');
expect(response.headers.getSetCookie()).toEqual(['__Host-poppy_ticket=ticket; Secure; HttpOnly; Path=/']);
expect(response.headers.get('retry-after')).toBe('30');
expect(response.headers.get('www-authenticate')).toBe('Bearer error="invalid_token"');
},
);

it.each([
{ type: 'browser' as const, endpoint, token: 'browser-token' },
{ type: 'session' as const, token: 'native-session-jwt' },
])('uses the unified resolver for $type credentials', async credential => {
server.use(
http.post(frontendApi + '/v1/poppy/browser_sessions/resolve', async ({ request }) => {
expect(request.headers.has('authorization')).toBe(false);
expect(await request.json()).toEqual({ credential });
return HttpResponse.json(identity);
}),
);
const { __experimental_poppy: poppy } = createBackendApiClient({ publishableKey });
await expect(poppy.resolveSession(credential)).resolves.toEqual(identity);
});

it('requires a publishable key', async () => {
const { __experimental_poppy: poppy } = createBackendApiClient({ secretKey: 'sk_live_example' });
await expect(poppy.resolveSession({ type: 'session', token: 'jwt' })).rejects.toThrow('publishable key');
});

it.each(['http://company.example/join', endpoint + '/', endpoint + '?query=true'])(
'rejects noncanonical endpoint %s',
value => {
expect(() =>
createPoppyBrowserSessionHandler({
clerkClient: createBackendApiClient({ publishableKey }),
browserSessionEndpoint: value,
}),
).toThrow('canonical HTTPS');
},
);

it('rejects an external callback destination', () => {
expect(() =>
createPoppyBrowserSessionHandler({
clerkClient: createBackendApiClient({ publishableKey }),
browserSessionEndpoint: endpoint,
redirectTo: 'https://other.example/',
}),
).toThrow('Company origin');
});
});
96 changes: 96 additions & 0 deletions packages/backend/src/api/endpoints/PoppyApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { runtime } from '../../runtime';
import type { RequestFunction } from '../request';

const browserSessions = '/v1/poppy/browser_sessions';

export const poppyCookies = {
browser: '__Host-poppy_session',
join: '__Host-poppy_join',
ticket: '__Host-poppy_ticket',
} as const;

export type PoppyBrowserSession = {
session_id: string;
client_id: string;
user_id: string;
agent_id?: string;
account?: { user_id: string; scopes: string[]; session_id: string };
};

export type PoppyMetadata = Record<string, unknown> & {
organization: { name: string; domain: string };
auth: { issuer: string; scopes: Record<string, string> };
};

export type PoppyOptions = {
/**
* Absolute HTTPS URL on the application origin for the Poppy-defined browser join.
* Clerk serves the join form here and derives Poppy's `/authorize` path and Clerk's `/callback` path from it.
* Next.js middleware also advertises this URL in discovery; mount `PoppyCallbackPage` at the callback path.
*/
browserSessionEndpoint: string;
/**
* Clerk-defined destination after a successful browser join. Signed-in joins visit the
* callback first to activate the Clerk session. Accepts a path or URL on the endpoint's origin.
* @default '/'
*/
redirectTo?: string;
};

export type PoppyCredential = { type: 'browser'; endpoint: string; token: string } | { type: 'session'; token: string };

export class PoppyAPI {
constructor(
private readonly request: RequestFunction,
private readonly artifactRequest: RequestFunction,
private readonly frontendApi?: string,
) {}

public async getMetadata() {
const artifact = await this.artifactRequest<{ object: 'artifact'; data: PoppyMetadata }>({
method: 'GET',
path: '/artifacts/well-known-poppy',
});
return artifact.data;
}

public resolveSession(credential: PoppyCredential) {
return this.request<PoppyBrowserSession>({
method: 'POST',
path: '/poppy/browser_sessions/resolve',
bodyParams: { credential },
});
}

public async proxyBrowserRequest(request: Request, options: PoppyOptions) {
if (!this.frontendApi) {
throw new Error('A valid Clerk publishable key is required for Poppy browser sessions.');
}
const endpoint = new URL(options.browserSessionEndpoint);
const incoming = new URL(request.url);
const headers = new Headers(Array.from(request.headers));
headers.delete('host');
headers.delete('content-length');
headers.set('accept-encoding', 'identity');
headers.set('Clerk-Poppy-Endpoint', endpoint.href);
headers.set('Clerk-Poppy-Redirect', new URL(options.redirectTo ?? '/', endpoint).href);
const body = request.method === 'POST' ? await request.arrayBuffer() : undefined;
const response = await runtime.fetch(
`https://${this.frontendApi}${browserSessions}${incoming.pathname === endpoint.pathname + '/authorize' ? '/authorize' : ''}`,
{
method: request.method,
headers: Object.fromEntries(headers),
body,
redirect: 'manual',
},
);
const responseHeaders = new Headers(Array.from(response.headers));
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}
}
9 changes: 9 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parsePublishableKey } from '@clerk/shared/keys';

import {
AccountlessApplicationAPI,
ActorTokenAPI,
Expand Down Expand Up @@ -36,16 +38,22 @@ import {
WebhookAPI,
} from './endpoints';
import { BillingAPI } from './endpoints/BillingApi';
import { PoppyAPI } from './endpoints/PoppyApi';
import { buildRequest } from './request';

export type CreateBackendApiOptions = Parameters<typeof buildRequest>[0] & {
jwtKey?: string;
publishableKey?: string;
};

export type ApiClient = ReturnType<typeof createBackendApiClient>;

export function createBackendApiClient(options: CreateBackendApiOptions) {
const request = buildRequest(options);
const frontendApi = parsePublishableKey(options.publishableKey)?.frontendApi;
const poppyBrowserRequest = frontendApi
? buildRequest({ apiUrl: `https://${frontendApi}`, requireSecretKey: false, userAgent: options.userAgent })
: () => Promise.reject(new Error('A valid Clerk publishable key is required for Poppy browser sessions.'));

return {
__experimental_accountlessApplications: new AccountlessApplicationAPI(
Expand Down Expand Up @@ -107,6 +115,7 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
organizations: new OrganizationAPI(request),
organizationPermissions: new OrganizationPermissionAPI(request),
organizationRoles: new OrganizationRoleAPI(request),
__experimental_poppy: new PoppyAPI(poppyBrowserRequest, request, frontendApi),
phoneNumbers: new PhoneNumberAPI(request),
proxyChecks: new ProxyCheckAPI(request),
redirectUrls: new RedirectUrlAPI(request),
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,7 @@ export type {
*/
export type { AuthObject, InvalidTokenAuthObject } from './tokens/authObjects';
export type { SessionAuthObject, MachineAuthObject } from './tokens/types';

export { createPoppyBrowserSessionHandler } from './poppyBrowserSession';
export { poppyCookies } from './api/endpoints/PoppyApi';
export type { PoppyBrowserSession, PoppyOptions } from './api/endpoints/PoppyApi';
12 changes: 6 additions & 6 deletions packages/backend/src/jwt/__tests__/assertions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,24 +192,24 @@ describe('assertHeaderAlgorithm(alg)', () => {
});

it('throws error if algorithm is missing', () => {
expect(() => assertHeaderAlgorithm('')).toThrow(`Invalid JWT algorithm "". Supported: RS256,RS384,RS512.`);
expect(() => assertHeaderAlgorithm('')).toThrow(`Invalid JWT algorithm "". Supported: RS256,RS384,RS512,EdDSA.`);
});

it('throws error if algorithm is not supported', () => {
expect(() => assertHeaderAlgorithm('ES256')).toThrow(
`Invalid JWT algorithm "ES256". Supported: RS256,RS384,RS512.`,
`Invalid JWT algorithm "ES256". Supported: RS256,RS384,RS512,EdDSA.`,
);
expect(() => assertHeaderAlgorithm('ES384')).toThrow(
`Invalid JWT algorithm "ES384". Supported: RS256,RS384,RS512.`,
`Invalid JWT algorithm "ES384". Supported: RS256,RS384,RS512,EdDSA.`,
);
expect(() => assertHeaderAlgorithm('ES512')).toThrow(
`Invalid JWT algorithm "ES512". Supported: RS256,RS384,RS512.`,
`Invalid JWT algorithm "ES512". Supported: RS256,RS384,RS512,EdDSA.`,
);
expect(() => assertHeaderAlgorithm('PS512')).toThrow(
`Invalid JWT algorithm "PS512". Supported: RS256,RS384,RS512.`,
`Invalid JWT algorithm "PS512". Supported: RS256,RS384,RS512,EdDSA.`,
);
expect(() => assertHeaderAlgorithm('Aloha')).toThrow(
`Invalid JWT algorithm "Aloha". Supported: RS256,RS384,RS512.`,
`Invalid JWT algorithm "Aloha". Supported: RS256,RS384,RS512,EdDSA.`,
);
});
});
Expand Down
Loading
Loading