diff --git a/.changeset/poppy-browser-session-v04.md b/.changeset/poppy-browser-session-v04.md
new file mode 100644
index 00000000000..daaa4a30e72
--- /dev/null
+++ b/.changeset/poppy-browser-session-v04.md
@@ -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.
diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts
index 7892bf9f554..acd013e5e6e 100644
--- a/packages/backend/src/__tests__/exports.test.ts
+++ b/packages/backend/src/__tests__/exports.test.ts
@@ -12,6 +12,8 @@ describe('public exports', () => {
expect(Object.keys(publicExports).sort()).toMatchInlineSnapshot(`
[
"createClerkClient",
+ "createPoppyBrowserSessionHandler",
+ "poppyCookies",
"verifyToken",
]
`);
diff --git a/packages/backend/src/api/__tests__/PoppyApi.test.ts b/packages/backend/src/api/__tests__/PoppyApi.test.ts
new file mode 100644
index 00000000000..d3afd0d4b70
--- /dev/null
+++ b/packages/backend/src/api/__tests__/PoppyApi.test.ts
@@ -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: '
' },
+ { 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');
+ });
+});
diff --git a/packages/backend/src/api/endpoints/PoppyApi.ts b/packages/backend/src/api/endpoints/PoppyApi.ts
new file mode 100644
index 00000000000..823024a4bd7
--- /dev/null
+++ b/packages/backend/src/api/endpoints/PoppyApi.ts
@@ -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 & {
+ organization: { name: string; domain: string };
+ auth: { issuer: string; scopes: Record };
+};
+
+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({
+ 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,
+ });
+ }
+}
diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts
index c9b6f700f0f..735b626c113 100644
--- a/packages/backend/src/api/factory.ts
+++ b/packages/backend/src/api/factory.ts
@@ -1,3 +1,5 @@
+import { parsePublishableKey } from '@clerk/shared/keys';
+
import {
AccountlessApplicationAPI,
ActorTokenAPI,
@@ -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[0] & {
jwtKey?: string;
+ publishableKey?: string;
};
export type ApiClient = ReturnType;
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(
@@ -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),
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 65b4c1127ff..fecd9adae6b 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -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';
diff --git a/packages/backend/src/jwt/__tests__/assertions.test.ts b/packages/backend/src/jwt/__tests__/assertions.test.ts
index 42ed0944e96..a838afb9244 100644
--- a/packages/backend/src/jwt/__tests__/assertions.test.ts
+++ b/packages/backend/src/jwt/__tests__/assertions.test.ts
@@ -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.`,
);
});
});
diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts
index a6df580696f..2f37ccd7d8c 100644
--- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts
+++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts
@@ -16,6 +16,8 @@ import {
someOtherPublicKey,
} from '../../fixtures';
import { mockSignedOAuthAccessTokenJwt, mockSignedOAuthAccessTokenJwtApplicationTyp } from '../../fixtures/machine';
+import { runtime } from '../../runtime';
+import { verifyToken } from '../../tokens/verify';
import { signJwt } from '../signJwt';
import { decodeJwt, hasValidSignature, verifyJwt } from '../verifyJwt';
@@ -25,6 +27,38 @@ const invalidTokenError = {
};
describe('hasValidSignature(jwt, key)', () => {
+ it('verifies Ed25519 signatures and rejects a changed payload or another key', async () => {
+ const pair = await runtime.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
+ const other = await runtime.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
+ const privateKey = await runtime.crypto.subtle.exportKey('jwk', pair.privateKey);
+ const publicKey = await runtime.crypto.subtle.exportKey('jwk', pair.publicKey);
+ const otherKey = await runtime.crypto.subtle.exportKey('jwk', other.publicKey);
+ const { data: signed } = await signJwt(
+ {
+ sub: 'user_test',
+ exp: Math.floor(Date.now() / 1000) + 60,
+ nbf: Math.floor(Date.now() / 1000) - 10,
+ iss: 'https://clerk.example.com',
+ },
+ privateKey,
+ { algorithm: 'EdDSA', header: { kid: 'ed25519-test', typ: 'JWT' } },
+ );
+ expect(signed).toBeDefined();
+ const { data: decoded } = decodeJwt(signed!);
+ expect((await hasValidSignature(decoded!, publicKey)).data).toBe(true);
+ const spki = await runtime.crypto.subtle.exportKey('spki', pair.publicKey);
+ const pem =
+ '-----BEGIN PUBLIC KEY-----\n' +
+ btoa(String.fromCharCode(...new Uint8Array(spki))) +
+ '\n-----END PUBLIC KEY-----';
+ expect((await verifyToken(signed!, { jwtKey: pem })).data?.sub).toBe('user_test');
+ expect((await hasValidSignature(decoded!, otherKey)).data).toBe(false);
+ const tampered = { ...decoded!, raw: { ...decoded!.raw, payload: decoded!.raw.payload + 'A' } };
+ expect((await hasValidSignature(tampered, publicKey)).data).toBe(false);
+ const rsaHeader = { ...decoded!, header: { ...decoded!.header, alg: 'RS256' } };
+ expect((await hasValidSignature(rsaHeader, publicKey)).data).not.toBe(true);
+ });
+
it('verifies the signature with a JWK formatted key', async () => {
const { data: decodedResult } = decodeJwt(signedJwt);
const { data: signatureResult } = decodedResult
diff --git a/packages/backend/src/jwt/algorithms.ts b/packages/backend/src/jwt/algorithms.ts
index 2fdfa0edc79..776d29dcadd 100644
--- a/packages/backend/src/jwt/algorithms.ts
+++ b/packages/backend/src/jwt/algorithms.ts
@@ -1,28 +1,18 @@
-const algToHash: Record = {
- RS256: 'SHA-256',
- RS384: 'SHA-384',
- RS512: 'SHA-512',
-};
const RSA_ALGORITHM_NAME = 'RSASSA-PKCS1-v1_5';
-const jwksAlgToCryptoAlg: Record = {
- RS256: RSA_ALGORITHM_NAME,
- RS384: RSA_ALGORITHM_NAME,
- RS512: RSA_ALGORITHM_NAME,
+const jwksAlgToCryptoAlg: Record = {
+ RS256: { name: RSA_ALGORITHM_NAME, hash: { name: 'SHA-256' } },
+ RS384: { name: RSA_ALGORITHM_NAME, hash: { name: 'SHA-384' } },
+ RS512: { name: RSA_ALGORITHM_NAME, hash: { name: 'SHA-512' } },
+ EdDSA: { name: 'Ed25519' },
};
-export const algs = Object.keys(algToHash);
-
-export function getCryptoAlgorithm(algorithmName: string): RsaHashedImportParams {
- const hash = algToHash[algorithmName];
- const name = jwksAlgToCryptoAlg[algorithmName];
+export const algs = Object.keys(jwksAlgToCryptoAlg);
- if (!hash || !name) {
+export function getCryptoAlgorithm(algorithmName: string): RsaHashedImportParams | Algorithm {
+ if (!algs.includes(algorithmName)) {
throw new Error(`Unsupported algorithm ${algorithmName}, expected one of ${algs.join(',')}.`);
}
- return {
- hash: { name: algToHash[algorithmName] },
- name: jwksAlgToCryptoAlg[algorithmName],
- };
+ return jwksAlgToCryptoAlg[algorithmName];
}
diff --git a/packages/backend/src/jwt/cryptoKeys.ts b/packages/backend/src/jwt/cryptoKeys.ts
index bdc1f42d6f3..4955db48386 100644
--- a/packages/backend/src/jwt/cryptoKeys.ts
+++ b/packages/backend/src/jwt/cryptoKeys.ts
@@ -23,7 +23,7 @@ function pemToBuffer(secret: string): Uint8Array {
export function importKey(
key: JsonWebKey | string,
- algorithm: RsaHashedImportParams,
+ algorithm: RsaHashedImportParams | Algorithm,
keyUsage: 'verify' | 'sign',
): Promise {
if (typeof key === 'object') {
diff --git a/packages/backend/src/poppyBrowserSession.ts b/packages/backend/src/poppyBrowserSession.ts
new file mode 100644
index 00000000000..b934fe87ff9
--- /dev/null
+++ b/packages/backend/src/poppyBrowserSession.ts
@@ -0,0 +1,26 @@
+import type { PoppyOptions } from './api/endpoints/PoppyApi';
+import type { ClerkClient } from './index';
+
+export function createPoppyBrowserSessionHandler(
+ options: PoppyOptions & { clerkClient: Pick },
+) {
+ const endpoint = new URL(options.browserSessionEndpoint);
+ if (
+ endpoint.protocol !== 'https:' ||
+ endpoint.username ||
+ endpoint.password ||
+ endpoint.search ||
+ endpoint.hash ||
+ endpoint.href !== options.browserSessionEndpoint ||
+ endpoint.pathname === '/' ||
+ endpoint.pathname.endsWith('/')
+ ) {
+ throw new Error('Expected a canonical HTTPS browser Session endpoint.');
+ }
+ const redirect = new URL(options.redirectTo ?? '/', endpoint);
+ if (redirect.origin !== endpoint.origin || redirect.username || redirect.password) {
+ throw new Error('The browser Session redirect must stay on the Company origin.');
+ }
+ const { clerkClient, ...poppyOptions } = options;
+ return (request: Request) => clerkClient.__experimental_poppy.proxyBrowserRequest(request, poppyOptions);
+}
diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts
index fde4ec53c75..7a96b9e79c9 100644
--- a/packages/backend/src/tokens/__tests__/authObjects.test.ts
+++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts
@@ -1,13 +1,17 @@
import type { JwtPayload } from '@clerk/shared/types';
-import { describe, expect, it, vi } from 'vitest';
+import { describe, expect, expectTypeOf, it, vi } from 'vitest';
+import type { PoppyBrowserSession } from '../../api/endpoints/PoppyApi';
import { createBackendApiClient } from '../../api/factory';
+import { createJwt, mockJwtPayload } from '../../fixtures';
import { mockTokens, mockVerificationResults } from '../../fixtures/machine';
+import { decodeJwt } from '../../jwt';
import type { AuthenticateContext } from '../authenticateContext';
import type { InvalidTokenAuthObject, UnauthenticatedMachineObject } from '../authObjects';
import {
authenticatedMachineObject,
getAuthObjectForAcceptedToken,
+ getAuthObjectFromJwt,
makeAuthObjectSerializable,
signedInAuthObject,
signedOutAuthObject,
@@ -31,6 +35,137 @@ describe('makeAuthObjectSerializable', () => {
});
describe('signedInAuthObject', () => {
+ const poppy: PoppyBrowserSession = {
+ client_id: 'https://agent.example',
+ session_id: 'protocol-session',
+ user_id: 'pseudonym',
+ };
+
+ it.each([
+ { name: 'ordinary Session has no agent', claims: {}, identity: undefined, expected: undefined },
+ {
+ name: 'impersonation remains actor attribution',
+ claims: { act: { sub: 'impersonator' } },
+ identity: undefined,
+ expected: undefined,
+ },
+ {
+ name: 'agent-task Session uses the existing Session agent shape',
+ claims: { agt: { id: 'agent_123', tsk: 'agtsk_123', reg: 'https://clerk.com' } },
+ identity: undefined,
+ expected: { type: 'agent', sub: 'agent_123', task_id: 'agtsk_123' },
+ },
+ {
+ name: 'guest identity does not attribute an independent human account to an agent',
+ claims: {},
+ identity: poppy,
+ expected: undefined,
+ },
+ {
+ name: 'guest protocol identity does not replace native agent-task attribution',
+ claims: { agt: { id: 'agent_123', tsk: 'agtsk_123' } },
+ identity: poppy,
+ expected: { type: 'agent', sub: 'agent_123', task_id: 'agtsk_123' },
+ },
+ {
+ name: 'unresolved JWT Poppy data is not exposed as verified protocol identity',
+ claims: { agt: { id: poppy.client_id, poppy } },
+ identity: undefined,
+ expected: { type: 'agent', sub: poppy.client_id },
+ },
+ {
+ name: 'malformed agent claim is not exposed',
+ claims: { agt: { id: 123, tsk: 'agtsk_123' } },
+ identity: undefined,
+ expected: undefined,
+ },
+ ])('$name', ({ claims, identity, expected }) => {
+ const auth = signedInAuthObject({ poppySession: identity }, 'token', {
+ ...mockJwtPayload,
+ __raw: 'token',
+ ...claims,
+ sub: 'user_123',
+ sid: 'sess_123',
+ });
+
+ expect(auth.agent).toEqual(expected);
+ expect(Object.keys(auth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ expect(auth.userId).toBe('user_123');
+ expect(auth.actor).toEqual('act' in claims ? claims.act : undefined);
+ expect(auth).not.toHaveProperty('poppy');
+ });
+
+ it('exposes current verified protocol IDs only on the authenticated agent', () => {
+ const current = {
+ ...poppy,
+ account: { user_id: 'user_123', session_id: 'sess_123', scopes: ['poppy:browser-sign-in'] },
+ };
+ const auth = signedInAuthObject({ poppySession: current }, 'token', {
+ ...mockJwtPayload,
+ __raw: 'token',
+ sub: 'user_123',
+ sid: 'sess_123',
+ agt: { id: poppy.client_id, poppy: { ...poppy, session_id: 'stale-session' } },
+ });
+
+ expect(auth.agent).toEqual({
+ type: 'agent',
+ sub: poppy.client_id,
+ __experimental_poppyClientId: current.client_id,
+ __experimental_poppyUserId: current.user_id,
+ __experimental_poppySessionId: current.session_id,
+ });
+ expectTypeOf(auth.agent?.__experimental_poppySessionId).toEqualTypeOf();
+ expect(Object.keys(auth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ expect(auth.userId).toBe(current.account.user_id);
+ expect(auth.sessionId).toBe(current.account.session_id);
+ });
+
+ it.each([
+ { name: 'another agent', client_id: 'https://another-agent.example', user_id: 'user_123', session_id: 'sess_123' },
+ { name: 'another account', client_id: poppy.client_id, user_id: 'user_other', session_id: 'sess_123' },
+ { name: 'another Clerk session', client_id: poppy.client_id, user_id: 'user_123', session_id: 'sess_other' },
+ ])('does not attach protocol identity from $name', ({ client_id, user_id, session_id }) => {
+ const auth = signedInAuthObject(
+ { poppySession: { ...poppy, client_id, account: { user_id, session_id, scopes: ['poppy:browser-sign-in'] } } },
+ 'token',
+ { ...mockJwtPayload, __raw: 'token', sub: 'user_123', sid: 'sess_123', agt: { id: poppy.client_id } },
+ );
+
+ expect(auth.agent).toEqual({ type: 'agent', sub: poppy.client_id });
+ expect(Object.keys(auth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ });
+
+ it('omits guest identity from the auth object', async () => {
+ const auth = signedOutAuthObject({ poppySession: poppy });
+
+ expect(auth).not.toHaveProperty('agent');
+ expect(Object.keys(auth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ expect(auth.userId).toBeNull();
+ expect(auth.sessionId).toBeNull();
+ expect(auth.isAuthenticated).toBe(false);
+ expect(auth.has({ permission: 'org:admin' })).toBe(false);
+ expect(await auth.getToken()).toBeNull();
+ expect(auth).not.toHaveProperty('poppy');
+ });
+
+ it('omits agent attribution when a pending Clerk Session is treated as signed out', () => {
+ const auth = getAuthObjectFromJwt(
+ decodeJwt(
+ createJwt({
+ payload: { sub: 'user_123', sid: 'sess_123', sts: 'pending', agt: { id: poppy.client_id } },
+ }),
+ ),
+ { poppySession: poppy },
+ );
+
+ expect(auth).not.toHaveProperty('agent');
+ expect(Object.keys(auth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ expect(auth.isAuthenticated).toBe(false);
+ expect(auth.userId).toBeNull();
+ expect(auth.sessionStatus).toBe('pending');
+ });
+
it('getToken returns the token passed in', async () => {
const mockAuthenticateContext = { sessionToken: 'authContextToken' } as AuthenticateContext;
const authObject = signedInAuthObject(mockAuthenticateContext, 'token', {
diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts
index 77db0fe2554..b049c21b528 100644
--- a/packages/backend/src/tokens/__tests__/request.test.ts
+++ b/packages/backend/src/tokens/__tests__/request.test.ts
@@ -1,6 +1,8 @@
+import { getCookieSuffix } from '@clerk/shared/keys';
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
+import { createBackendApiClient } from '../../api/factory';
import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors';
import {
mockExpiredJwt,
@@ -509,6 +511,360 @@ describe('tokens.authenticateRequest(options)', () => {
vi.clearAllMocks();
});
+ describe('Poppy account precedence', () => {
+ const endpoint = 'https://example.com/poppy/browser-session';
+ const browserToken = 'a'.repeat(40);
+ const identity = { session_id: 'protocol-session', client_id: 'https://agent.example', user_id: 'pseudonym' };
+ const account = { user_id: mockJwtPayload.sub, session_id: mockJwtPayload.sid, scopes: ['poppy:browser-sign-in'] };
+
+ it.each([
+ { name: 'guest join preserves an independent Clerk login', body: identity, authenticated: true },
+ {
+ name: 'signed-in join accepts its matching Clerk Session',
+ body: { ...identity, account },
+ authenticated: true,
+ },
+ {
+ name: 'pending join rejects an earlier account',
+ body: { ...identity, account: { ...account, session_id: 'sess_other' } },
+ authenticated: false,
+ },
+ ])('$name', async ({ body, authenticated }) => {
+ let requests = 0;
+ server.use(
+ http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)),
+ http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', async ({ request }) => {
+ expect(await request.json()).toEqual({ credential: { type: 'browser', endpoint, token: browserToken } });
+ requests++;
+ return HttpResponse.json(body);
+ }),
+ );
+ const state = await authenticateRequest(
+ mockRequestWithHeaderAuth({ cookie: `__Host-poppy_session=${browserToken}` }, 'https://example.com/path'),
+ mockOptions({
+ apiClient: createBackendApiClient({ publishableKey: PK_TEST }),
+ __experimental_poppy: { browserSessionEndpoint: endpoint },
+ }),
+ );
+ expect(state.isAuthenticated).toBe(authenticated);
+ const auth = state.toAuth();
+ expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ expect(auth).not.toHaveProperty('agent');
+ expect(state.poppy).toEqual(body);
+ expect(requests).toBe(1);
+ if (!authenticated) {
+ expect(state.reason).toBe(AuthErrorReason.PoppyAccountInvalid);
+ }
+ });
+
+ it.each([
+ {
+ name: 'verified guest renders without development browser sync',
+ cookies: {},
+ body: identity,
+ status: 200,
+ handshake: false,
+ },
+ {
+ name: 'missing guest identity retains development browser sync',
+ cookies: {},
+ body: null,
+ status: 404,
+ handshake: true,
+ },
+ {
+ name: 'existing native Session retains development browser sync',
+ cookies: { __session: mockJwt },
+ body: identity,
+ status: 200,
+ handshake: true,
+ },
+ {
+ name: 'expired native Session retains development browser sync',
+ cookies: { __session: mockExpiredJwt },
+ body: identity,
+ status: 200,
+ handshake: true,
+ },
+ {
+ name: 'existing native client authority retains development browser sync',
+ cookies: { __client_uat: '12345' },
+ body: identity,
+ status: 200,
+ handshake: true,
+ },
+ {
+ name: 'native refresh credential retains development browser sync',
+ cookies: { __refresh: 'refresh-token' },
+ body: identity,
+ status: 200,
+ handshake: true,
+ },
+ {
+ name: 'pending account activation retains development browser sync',
+ cookies: {},
+ body: { ...identity, account },
+ status: 200,
+ handshake: true,
+ },
+ ])('$name', async ({ cookies, body, status, handshake }) => {
+ const suffix = await getCookieSuffix(PK_TEST);
+ const nativeCookies = Object.fromEntries(
+ Object.entries(cookies).map(([name, value]) => [name === '__refresh' ? `__refresh_${suffix}` : name, value]),
+ );
+ let lookups = 0;
+ server.use(
+ http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)),
+ http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () => {
+ lookups++;
+ return HttpResponse.json(body, { status });
+ }),
+ );
+ const state = await authenticateRequest(
+ mockRequestWithCookies(
+ {},
+ { '__Host-poppy_session': browserToken, ...nativeCookies },
+ 'https://example.com/path',
+ ),
+ mockOptions({
+ apiClient: createBackendApiClient({ publishableKey: PK_TEST }),
+ __experimental_poppy: { browserSessionEndpoint: endpoint },
+ }),
+ );
+ expect(state.status).toBe(handshake ? 'handshake' : 'signed-out');
+ expect(lookups).toBe(1);
+ if (!handshake) {
+ expect(state.headers.has('location')).toBe(false);
+ expect(state.poppy).toEqual(identity);
+ const auth = state.toAuth();
+ expect(auth).not.toHaveProperty('agent');
+ expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ }
+ });
+
+ it.each([
+ {
+ name: 'valid stale Poppy Session',
+ poppy: true,
+ expired: false,
+ forged: false,
+ devBrowser: false,
+ rejected: true,
+ },
+ {
+ name: 'valid stale Poppy Session with development browser',
+ poppy: true,
+ expired: false,
+ forged: false,
+ devBrowser: true,
+ rejected: true,
+ },
+ {
+ name: 'expired stale Poppy Session',
+ poppy: true,
+ expired: true,
+ forged: false,
+ devBrowser: false,
+ rejected: true,
+ },
+ {
+ name: 'expired stale Poppy Session with development browser',
+ poppy: true,
+ expired: true,
+ forged: false,
+ devBrowser: true,
+ rejected: true,
+ },
+ { name: 'forged Poppy Session', poppy: true, expired: false, forged: true, devBrowser: false, rejected: false },
+ {
+ name: 'expired forged Poppy Session',
+ poppy: true,
+ expired: true,
+ forged: true,
+ devBrowser: false,
+ rejected: false,
+ },
+ {
+ name: 'independent human Session',
+ poppy: false,
+ expired: false,
+ forged: false,
+ devBrowser: false,
+ rejected: false,
+ },
+ {
+ name: 'expired independent human Session',
+ poppy: false,
+ expired: true,
+ forged: false,
+ devBrowser: false,
+ rejected: false,
+ },
+ ])(
+ '$name does not grant account access after guest rejoin',
+ async ({ poppy, expired, forged, devBrowser, rejected }) => {
+ const { data: token } = await signJwt(
+ {
+ ...mockJwtPayload,
+ ...(expired ? { exp: mockJwtPayload.iat - 60 } : {}),
+ ...(poppy ? { agt: { id: 'client', poppy: { session_id: 'protocol-session' } } } : {}),
+ },
+ signingJwks,
+ {
+ algorithm: 'RS256',
+ header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
+ },
+ );
+ if (!token) {
+ throw new Error('Failed to create the test JWT.');
+ }
+ const parts = token.split('.');
+ if (forged) {
+ parts[2] = (parts[2].startsWith('A') ? 'B' : 'A') + parts[2].slice(1);
+ }
+ let keyLoads = 0;
+ let lookups = 0;
+ server.use(
+ http.get('https://api.clerk.test/v1/jwks', () => {
+ keyLoads++;
+ return HttpResponse.json(mockJwks);
+ }),
+ http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () => {
+ lookups++;
+ return HttpResponse.json(identity);
+ }),
+ );
+ const state = await authenticateRequest(
+ mockRequestWithCookies(
+ {},
+ {
+ '__Host-poppy_session': browserToken,
+ __session: parts.join('.'),
+ __client_uat: '0',
+ ...(devBrowser ? { __clerk_db_jwt: 'development-browser' } : {}),
+ },
+ 'https://example.com/path',
+ ),
+ mockOptions({
+ apiClient: createBackendApiClient({ publishableKey: PK_TEST }),
+ __experimental_poppy: { browserSessionEndpoint: endpoint },
+ }),
+ );
+ expect(state.isAuthenticated).toBe(false);
+ expect(lookups).toBe(1);
+ expect(keyLoads).toBe(1);
+ expect(state.poppy).toEqual(identity);
+ if (rejected) {
+ expect(state.status).toBe('signed-out');
+ expect(state.reason).toBe(AuthErrorReason.PoppyAccountInvalid);
+ expect(state.headers.has('location')).toBe(false);
+ const auth = state.toAuth();
+ expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ expect(auth && 'userId' in auth ? auth.userId : undefined).toBeNull();
+ expect(auth).not.toHaveProperty('agent');
+ } else {
+ expect(state.status).toBe('handshake');
+ expect(state.headers.has('location')).toBe(true);
+ }
+ },
+ );
+
+ it.each(['authority store', 'JWKS'] as const)(
+ 'propagates a stale Session %s failure without treating it as revocation',
+ async failing => {
+ vi.useRealTimers();
+ server.use(
+ http.get('https://api.clerk.test/v1/jwks', () =>
+ HttpResponse.json(failing === 'JWKS' ? { errors: [] } : mockJwks, {
+ status: failing === 'JWKS' ? 503 : 200,
+ }),
+ ),
+ http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () =>
+ HttpResponse.json(failing === 'authority store' ? { errors: [] } : identity, {
+ status: failing === 'authority store' ? 503 : 200,
+ }),
+ ),
+ );
+ const result = authenticateRequest(
+ mockRequestWithCookies(
+ {},
+ {
+ '__Host-poppy_session': browserToken,
+ __session: mockJwt,
+ __client_uat: '0',
+ },
+ 'https://example.com/path',
+ ),
+ mockOptions({
+ apiClient: createBackendApiClient({ publishableKey: PK_TEST }),
+ __experimental_poppy: { browserSessionEndpoint: endpoint },
+ }),
+ );
+ await expect(result).rejects.toMatchObject(
+ failing === 'authority store'
+ ? { status: 503 }
+ : { reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad },
+ );
+ },
+ );
+
+ it.each([true, false])('checks a Poppy JWT without its browser cookie; live grant=%s', async active => {
+ const { data: jwt } = await signJwt(
+ { ...mockJwtPayload, agt: { id: identity.client_id, poppy: { session_id: 'protocol-session' } } },
+ signingJwks,
+ {
+ algorithm: 'RS256',
+ header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
+ },
+ );
+ server.use(
+ http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)),
+ http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', async ({ request }) => {
+ expect(await request.json()).toEqual({ credential: { type: 'session', token: jwt } });
+ return HttpResponse.json(active ? { ...identity, account } : { errors: [] }, { status: active ? 200 : 401 });
+ }),
+ );
+ const state = await authenticateRequest(
+ mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }),
+ mockOptions({ apiClient: createBackendApiClient({ publishableKey: PK_TEST }) }),
+ );
+ expect(state.isAuthenticated).toBe(active);
+ const auth = state.toAuth();
+ if (active) {
+ expect(auth).toMatchObject({
+ agent: {
+ type: 'agent',
+ sub: identity.client_id,
+ __experimental_poppyClientId: identity.client_id,
+ __experimental_poppyUserId: identity.user_id,
+ __experimental_poppySessionId: identity.session_id,
+ },
+ });
+ expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ } else {
+ expect(auth).not.toHaveProperty('agent');
+ }
+ });
+
+ it('propagates an authority store outage', async () => {
+ server.use(
+ http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)),
+ http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () =>
+ HttpResponse.json({ errors: [] }, { status: 503 }),
+ ),
+ );
+ await expect(
+ authenticateRequest(
+ mockRequestWithHeaderAuth({ cookie: `__Host-poppy_session=${browserToken}` }, 'https://example.com/path'),
+ mockOptions({
+ apiClient: createBackendApiClient({ publishableKey: PK_TEST }),
+ __experimental_poppy: { browserSessionEndpoint: endpoint },
+ }),
+ ),
+ ).rejects.toMatchObject({ status: 503 });
+ });
+ });
+
//
// HTTP Authorization exists
//
diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts
index 44391b388da..c8a31d95a76 100644
--- a/packages/backend/src/tokens/authObjects.ts
+++ b/packages/backend/src/tokens/authObjects.ts
@@ -1,6 +1,7 @@
import { createCheckAuthorization } from '@clerk/shared/authorization';
import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser';
import type {
+ AgentActClaim,
CheckAuthorizationFromSessionClaims,
Jwt,
JwtPayload,
@@ -13,6 +14,7 @@ import type {
import type { APIKey, CreateBackendApiOptions, IdPOAuthAccessToken, M2MToken } from '../api';
import { createBackendApiClient } from '../api';
+import type { PoppyBrowserSession } from '../api/endpoints/PoppyApi';
import { isTokenTypeAccepted } from '../internal';
import type { AuthenticateContext } from './authenticateContext';
import { isMachineTokenType } from './machine';
@@ -31,6 +33,33 @@ type AuthObjectDebug = () => AuthObjectDebugData;
type Claims = Record;
+type AuthAgent = AgentActClaim & {
+ __experimental_poppyClientId?: string;
+ __experimental_poppyUserId?: string;
+ __experimental_poppySessionId?: string;
+};
+
+const getAuthAgent = (claims: JwtPayload, session?: PoppyBrowserSession | null): AuthAgent | undefined => {
+ const claim: unknown = claims.agt;
+ if (typeof claim === 'object' && claim !== null && 'id' in claim && typeof claim.id === 'string' && claim.id) {
+ return {
+ type: 'agent',
+ sub: claim.id,
+ ...('tsk' in claim && typeof claim.tsk === 'string' ? { task_id: claim.tsk } : {}),
+ ...(session?.account?.user_id === claims.sub &&
+ session.account.session_id === claims.sid &&
+ session.client_id === claim.id
+ ? {
+ __experimental_poppyClientId: session.client_id,
+ __experimental_poppyUserId: session.user_id,
+ __experimental_poppySessionId: session.session_id,
+ }
+ : {}),
+ };
+ }
+ return undefined;
+};
+
/**
* @internal
*/
@@ -42,6 +71,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & {
* @internal
*/
export type SignedInAuthObject = SharedSignedInAuthObjectProperties & {
+ agent?: AuthAgent;
/**
* The allowed token type.
*/
@@ -65,6 +95,7 @@ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & {
* @internal
*/
export type SignedOutAuthObject = {
+ agent?: undefined;
sessionClaims: null;
sessionId: null;
sessionStatus: SessionStatusClaim | null;
@@ -198,6 +229,7 @@ export function signedInAuthObject(
const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } =
__experimental_JWTPayloadToAuthObjectProperties(sessionClaims);
const apiClient = createBackendApiClient(authenticateContext);
+ const agent = getAuthAgent(sessionClaims, authenticateContext.poppySession);
const getToken = createGetToken({
sessionId,
sessionToken,
@@ -206,6 +238,7 @@ export function signedInAuthObject(
});
return {
tokenType: TokenType.SessionToken,
+ ...(agent ? { agent } : {}),
actor,
sessionClaims,
sessionId,
diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts
index 5282aa4b3a8..6952f22aad7 100644
--- a/packages/backend/src/tokens/authStatus.ts
+++ b/packages/backend/src/tokens/authStatus.ts
@@ -1,5 +1,6 @@
import type { JwtPayload, PendingSessionOptions } from '@clerk/shared/types';
+import type { PoppyBrowserSession } from '../api/endpoints/PoppyApi';
import { constants } from '../constants';
import type { TokenVerificationErrorReason } from '../errors';
import type { AuthenticateContext } from './authenticateContext';
@@ -40,6 +41,7 @@ type ToAuth = T exten
: () => UnauthenticatedMachineObject>;
export type AuthenticatedState = {
+ poppy?: PoppyBrowserSession | null;
status: typeof AuthStatus.SignedIn;
reason: null;
message: null;
@@ -63,6 +65,7 @@ export type AuthenticatedState = {
};
export type UnauthenticatedState = {
+ poppy?: PoppyBrowserSession | null;
status: typeof AuthStatus.SignedOut;
reason: AuthReason;
message: string;
@@ -118,6 +121,7 @@ export const AuthErrorReason = {
SessionTokenWithoutClientUAT: 'session-token-but-no-client-uat',
ActiveOrganizationMismatch: 'active-organization-mismatch',
TokenTypeMismatch: 'token-type-mismatch',
+ PoppyAccountInvalid: 'poppy-account-invalid',
UnexpectedError: 'unexpected-error',
} as const;
@@ -150,7 +154,10 @@ export function signedIn(params: SignedInParams & { tokenTy
const authObject = signedInAuthObject(authenticateContext, token, sessionClaims);
if (treatPendingAsSignedOut && authObject.sessionStatus === 'pending') {
- return signedOutAuthObject(undefined, authObject.sessionStatus);
+ return signedOutAuthObject(
+ authenticateContext.poppySession ? { poppySession: authenticateContext.poppySession } : undefined,
+ authObject.sessionStatus,
+ );
}
return authObject;
diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts
index 427b49ae7b3..f720435944d 100644
--- a/packages/backend/src/tokens/authenticateContext.ts
+++ b/packages/backend/src/tokens/authenticateContext.ts
@@ -3,6 +3,7 @@ import { getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy';
import type { Jwt } from '@clerk/shared/types';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
+import type { PoppyBrowserSession } from '../api/endpoints/PoppyApi';
import { constants } from '../constants';
import { decodeJwt } from '../jwt/verifyJwt';
import { runtime } from '../runtime';
@@ -13,6 +14,7 @@ import { TokenType } from './tokenTypes';
import type { AuthenticateRequestOptions } from './types';
interface AuthenticateContext extends AuthenticateRequestOptions {
+ poppySession?: PoppyBrowserSession | null;
// header-based values
accept: string | undefined;
forwardedHost: string | undefined;
diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts
index 9c6afb45db2..8029a71656f 100644
--- a/packages/backend/src/tokens/handshake.ts
+++ b/packages/backend/src/tokens/handshake.ts
@@ -79,7 +79,7 @@ export async function verifyHandshakeToken(
let key;
if (jwtKey) {
- key = loadClerkJwkFromPem({ kid, pem: jwtKey });
+ key = data.header.alg === 'EdDSA' ? jwtKey : loadClerkJwkFromPem({ kid, pem: jwtKey });
} else if (secretKey) {
// Fetch JWKS from Backend API using the key
key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache });
diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts
index 07f16f0a97e..eb3659a90a8 100644
--- a/packages/backend/src/tokens/request.ts
+++ b/packages/backend/src/tokens/request.ts
@@ -1,6 +1,9 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
import { logger } from '@clerk/shared/logger';
import type { JwtPayload } from '@clerk/shared/types';
+import type { PoppyBrowserSession, PoppyCredential } from '../api/endpoints/PoppyApi';
+import { poppyCookies } from '../api/endpoints/PoppyApi';
import { constants } from '../constants';
import type { TokenCarrier } from '../errors';
import { MachineTokenVerificationError, TokenVerificationError, TokenVerificationErrorReason } from '../errors';
@@ -150,7 +153,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
request: Request,
options: AuthenticateRequestOptions,
): Promise | UnauthenticatedState> => {
- const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options);
+ const clerkRequest = createClerkRequest(request);
+ const authenticateContext = await createAuthenticateContext(clerkRequest, options);
// Default tokenType is session_token for backwards compatibility.
const acceptsToken = options.acceptsToken ?? TokenType.SessionToken;
@@ -895,18 +899,16 @@ export const authenticateRequest: AuthenticateRequest = (async (
}
}
+ let state: RequestState | UnauthenticatedState;
if (authenticateContext.tokenInHeader) {
if (acceptsToken === 'any' || Array.isArray(acceptsToken)) {
- return authenticateAnyRequestWithTokenInHeader();
- }
- if (acceptsToken === TokenType.SessionToken) {
- return authenticateRequestWithTokenInHeader();
+ state = await authenticateAnyRequestWithTokenInHeader();
+ } else if (acceptsToken === TokenType.SessionToken) {
+ state = await authenticateRequestWithTokenInHeader();
+ } else {
+ state = await authenticateMachineRequestWithTokenInHeader();
}
- return authenticateMachineRequestWithTokenInHeader();
- }
-
- // Machine requests cannot have the token in the cookie, it must be in header.
- if (
+ } else if (
acceptsToken === TokenType.OAuthToken ||
acceptsToken === TokenType.ApiKey ||
acceptsToken === TokenType.M2MToken
@@ -916,14 +918,133 @@ export const authenticateRequest: AuthenticateRequest = (async (
authenticateContext,
reason: 'No token in header',
});
+ } else {
+ state = await authenticateRequestWithTokenInCookie();
+ }
+
+ if (state.tokenType !== TokenType.SessionToken) {
+ return state;
+ }
+
+ const rejectPoppyAccount = () => {
+ const rejected = signedOut({
+ tokenType: TokenType.SessionToken,
+ authenticateContext,
+ reason: AuthErrorReason.PoppyAccountInvalid,
+ message: 'Poppy browser account authority is no longer valid.',
+ });
+ rejected.poppy = authenticateContext.poppySession;
+ return rejected;
+ };
+ const resolvePoppy = async (credential: PoppyCredential): Promise => {
+ if (!options.apiClient) {
+ throw new Error('A Clerk API client is required to verify Poppy account authority.');
+ }
+ try {
+ return await options.apiClient.__experimental_poppy.resolveSession(credential);
+ } catch (error) {
+ if (isClerkAPIResponseError(error) && (error.status === 401 || error.status === 404)) {
+ return null;
+ }
+ throw error;
+ }
+ };
+
+ const browserToken = clerkRequest.cookies.get(poppyCookies.browser);
+ let joined: PoppyBrowserSession | null = null;
+ if (browserToken && options.__experimental_poppy) {
+ const endpoint = options.__experimental_poppy.browserSessionEndpoint;
+ if (new URL(endpoint).origin !== clerkRequest.clerkUrl.origin) {
+ throw new Error('The Poppy browser Session endpoint must use the Company origin.');
+ }
+ joined = await resolvePoppy({ type: 'browser', endpoint, token: browserToken });
+ authenticateContext.poppySession = joined;
+ state.poppy = joined;
+ }
+
+ if (state.status === 'handshake') {
+ const sessionToken = authenticateContext.sessionTokenInCookie;
+ if (
+ joined &&
+ !joined.account &&
+ sessionToken &&
+ !isMachineJwt(sessionToken) &&
+ !hasNonSessionJwtCategory(sessionToken)
+ ) {
+ const { data, errors } = await verifyToken(sessionToken, authenticateContext);
+ let claims = data;
+ if (errors) {
+ const error: unknown = errors[0];
+ if (!(error instanceof TokenVerificationError)) {
+ throw error instanceof Error ? error : new Error('Native Clerk Session verification failed.');
+ }
+ switch (error.reason) {
+ case TokenVerificationErrorReason.TokenExpired:
+ claims = decodeJwt(sessionToken).data?.payload;
+ break;
+ case TokenVerificationErrorReason.TokenInvalid:
+ case TokenVerificationErrorReason.TokenInvalidAlgorithm:
+ case TokenVerificationErrorReason.TokenInvalidAuthorizedParties:
+ case TokenVerificationErrorReason.TokenInvalidIssuer:
+ case TokenVerificationErrorReason.TokenInvalidSignature:
+ case TokenVerificationErrorReason.TokenNotActiveYet:
+ case TokenVerificationErrorReason.TokenIatInTheFuture:
+ return state;
+ default:
+ throw error;
+ }
+ }
+ const agent: unknown = claims?.agt;
+ if (typeof claims?.sid === 'string' && typeof agent === 'object' && agent !== null && 'poppy' in agent) {
+ return rejectPoppyAccount();
+ }
+ }
+ if (
+ state.reason === AuthErrorReason.DevBrowserMissing &&
+ joined &&
+ !joined.account &&
+ !authenticateContext.sessionToken &&
+ !authenticateContext.clientUat &&
+ !authenticateContext.refreshTokenInCookie
+ ) {
+ const guest = signedOut({
+ tokenType: TokenType.SessionToken,
+ authenticateContext,
+ reason: AuthErrorReason.SessionTokenAndUATMissing,
+ });
+ guest.poppy = joined;
+ return guest;
+ }
+ return state;
}
- return authenticateRequestWithTokenInCookie();
+ if (!state.isAuthenticated) {
+ return state;
+ }
+ const auth = state.toAuth();
+ if (auth.tokenType !== TokenType.SessionToken) {
+ return state;
+ }
+ if (joined?.account && (joined.account.session_id !== auth.sessionId || joined.account.user_id !== auth.userId)) {
+ return rejectPoppyAccount();
+ }
+ const agent: unknown = auth.sessionClaims.agt;
+ const isPoppySession = typeof agent === 'object' && agent !== null && 'poppy' in agent;
+ if (!isPoppySession) {
+ return state;
+ }
+ const current =
+ browserToken && options.__experimental_poppy ? joined : await resolvePoppy({ type: 'session', token: state.token });
+ if (!current?.account || current.account.session_id !== auth.sessionId || current.account.user_id !== auth.userId) {
+ return rejectPoppyAccount();
+ }
+ if (!joined) {
+ authenticateContext.poppySession = current;
+ state.poppy = current;
+ }
+ return state;
}) as AuthenticateRequest;
-/**
- * @internal
- */
export const debugRequestState = (params: RequestState) => {
const { isSignedIn, isAuthenticated, proxyUrl, reason, message, publishableKey, isSatellite, domain } = params;
return { isSignedIn, isAuthenticated, proxyUrl, reason, message, publishableKey, isSatellite, domain };
diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts
index f54724da76b..aafde10a6ec 100644
--- a/packages/backend/src/tokens/types.ts
+++ b/packages/backend/src/tokens/types.ts
@@ -2,6 +2,7 @@ import type { MatchFunction } from '@clerk/shared/pathToRegexp';
import type { PendingSessionOptions } from '@clerk/shared/types';
import type { ApiClient, APIKey, IdPOAuthAccessToken, M2MToken } from '../api';
+import type { PoppyOptions } from '../api/endpoints/PoppyApi';
import type {
AuthenticatedMachineObject,
AuthObject,
@@ -17,6 +18,12 @@ import type { VerifyTokenOptions } from './verify';
* @interface
*/
export type AuthenticateRequestOptions = {
+ /**
+ * Clerk's experimental configuration for Poppy browser sessions. Enables live account-authority
+ * checks for the joined browser; in `clerkMiddleware()`, also serves discovery and join requests.
+ * These SDK options may change while Poppy is a draft.
+ */
+ __experimental_poppy?: PoppyOptions;
/**
* The Clerk Publishable Key from the [**API keys**](https://dashboard.clerk.com/~/api-keys) page in the Clerk Dashboard.
*/
diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts
index b20387f93d4..3aa451ed2b6 100644
--- a/packages/backend/src/tokens/verify.ts
+++ b/packages/backend/src/tokens/verify.ts
@@ -135,10 +135,10 @@ export async function verifyToken(
}
try {
- let key: JsonWebKey;
+ let key: JsonWebKey | string;
if (options.jwtKey) {
- key = loadClerkJwkFromPem({ kid, pem: options.jwtKey });
+ key = decodedResult.header.alg === 'EdDSA' ? options.jwtKey : loadClerkJwkFromPem({ kid, pem: options.jwtKey });
} else if (options.secretKey) {
key = await loadClerkJWKFromRemote({ ...options, kid });
} else {
diff --git a/packages/nextjs/src/app-router/client/PoppyCallback.tsx b/packages/nextjs/src/app-router/client/PoppyCallback.tsx
new file mode 100644
index 00000000000..357df87b310
--- /dev/null
+++ b/packages/nextjs/src/app-router/client/PoppyCallback.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { useSignIn } from '@clerk/react/legacy';
+import React, { useEffect, useRef, useState } from 'react';
+
+export function PoppyCallback({ ticket, redirectTo }: { ticket: string; redirectTo: string }) {
+ const { isLoaded, signIn, setActive } = useSignIn();
+ const hasRun = useRef(false);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ if (!isLoaded || hasRun.current) {
+ return;
+ }
+ hasRun.current = true;
+ void (async () => {
+ try {
+ const result = await signIn.create({ strategy: 'ticket', ticket });
+ if (result.status !== 'complete' || !result.createdSessionId) {
+ setFailed(true);
+ return;
+ }
+ await setActive({ session: result.createdSessionId, redirectUrl: redirectTo });
+ } catch {
+ setFailed(true);
+ }
+ })();
+ }, [isLoaded, signIn, setActive, ticket, redirectTo]);
+
+ return {failed ? 'Start the browser connection again.' : 'Connecting this browser.'}
;
+}
diff --git a/packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx b/packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx
new file mode 100644
index 00000000000..083a6e4cb72
--- /dev/null
+++ b/packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx
@@ -0,0 +1,70 @@
+import { useSignIn } from '@clerk/react/legacy';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import React, { StrictMode } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { PoppyCallback } from '../PoppyCallback';
+
+vi.mock('@clerk/react/legacy', () => ({ useSignIn: vi.fn() }));
+
+const create = vi.fn();
+const setActive = vi.fn();
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ create.mockResolvedValue({ status: 'complete', createdSessionId: 'sess_agent' });
+ setActive.mockResolvedValue(undefined);
+ vi.mocked(useSignIn, { partial: true, deep: true }).mockReturnValue({
+ isLoaded: true,
+ signIn: { create },
+ setActive,
+ });
+});
+
+afterEach(cleanup);
+
+describe('PoppyCallback', () => {
+ it('redeems the ticket once through normal sign-in and activates its Session', async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(setActive).toHaveBeenCalledWith({ session: 'sess_agent', redirectUrl: 'https://company.example/cart' }),
+ );
+ expect(create).toHaveBeenCalledExactlyOnceWith({ strategy: 'ticket', ticket: 'one-use-ticket' });
+ expect(setActive).toHaveBeenCalledTimes(1);
+ expect(document.location.href).not.toContain('one-use-ticket');
+ });
+
+ it.each([
+ { status: 'needs_second_factor', createdSessionId: null },
+ { status: 'complete', createdSessionId: null },
+ ])('does not activate incomplete sign-in $status', async result => {
+ create.mockResolvedValue(result);
+ render(
+ ,
+ );
+ await screen.findByText('Start the browser connection again.');
+ expect(setActive).not.toHaveBeenCalled();
+ });
+
+ it('shows a restart action when ticket redemption fails', async () => {
+ create.mockRejectedValue(new Error('Ticket expired'));
+ render(
+ ,
+ );
+ await screen.findByText('Start the browser connection again.');
+ expect(setActive).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/nextjs/src/server/PoppyCallbackPage.tsx b/packages/nextjs/src/server/PoppyCallbackPage.tsx
new file mode 100644
index 00000000000..13df50eaca4
--- /dev/null
+++ b/packages/nextjs/src/server/PoppyCallbackPage.tsx
@@ -0,0 +1,31 @@
+import { poppyCookies } from '@clerk/backend';
+import { constants } from '@clerk/backend/internal';
+import { cookies, headers } from 'next/headers';
+import React from 'react';
+
+import { PoppyCallback } from '../app-router/client/PoppyCallback';
+import { decryptClerkRequestData } from './utils';
+
+export async function PoppyCallbackPage() {
+ const requestHeaders = await headers();
+ const options = decryptClerkRequestData(requestHeaders.get(constants.Headers.ClerkRequestData));
+ const endpoint = options.__experimental_poppy && new URL(options.__experimental_poppy.browserSessionEndpoint);
+ const currentUrl = requestHeaders.get(constants.Headers.ClerkUrl);
+ if (!endpoint || !currentUrl || new URL(currentUrl).href !== endpoint.href + '/callback') {
+ throw new Error('The Poppy callback requires its configured Clerk middleware route.');
+ }
+ const ticket = (await cookies()).get(poppyCookies.ticket)?.value;
+ if (!ticket) {
+ return Start the browser connection again.
;
+ }
+ const redirect = new URL(options.__experimental_poppy?.redirectTo ?? '/', endpoint);
+ if (redirect.origin !== endpoint.origin || redirect.username || redirect.password) {
+ throw new Error('The browser Session redirect must stay on the Company origin.');
+ }
+ return (
+
+ );
+}
diff --git a/packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx b/packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx
new file mode 100644
index 00000000000..b42e0f6dc8d
--- /dev/null
+++ b/packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx
@@ -0,0 +1,40 @@
+import { constants } from '@clerk/backend/internal';
+import { cookies, headers } from 'next/headers';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { PoppyCallbackPage } from '../PoppyCallbackPage';
+import { decryptClerkRequestData } from '../utils';
+
+vi.mock('next/headers', () => ({ headers: vi.fn(), cookies: vi.fn() }));
+vi.mock('../utils', () => ({ decryptClerkRequestData: vi.fn() }));
+
+const endpoint = 'https://company.example/poppy/browser-session';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(decryptClerkRequestData).mockReturnValue({
+ __experimental_poppy: { browserSessionEndpoint: endpoint, redirectTo: '/cart' },
+ });
+ vi.mocked(headers).mockResolvedValue(new Headers({ [constants.Headers.ClerkUrl]: endpoint + '/callback' }));
+ vi.mocked(cookies, { partial: true }).mockResolvedValue({
+ get: vi.fn().mockReturnValue({ value: 'one-use-ticket' }),
+ });
+});
+
+describe('PoppyCallbackPage', () => {
+ it('passes the protected ticket to the native sign-in callback without URL transport', async () => {
+ const page = await PoppyCallbackPage();
+ expect(page.props).toEqual({ ticket: 'one-use-ticket', redirectTo: 'https://company.example/cart' });
+ });
+
+ it('requires a configured middleware callback', async () => {
+ vi.mocked(decryptClerkRequestData).mockReturnValue({});
+ await expect(PoppyCallbackPage()).rejects.toThrow('configured Clerk middleware');
+ });
+
+ it('shows a restart action when the ticket cookie is missing', async () => {
+ vi.mocked(cookies, { partial: true }).mockResolvedValue({ get: vi.fn() });
+ const page = await PoppyCallbackPage();
+ expect(page.props.children).toBe('Start the browser connection again.');
+ });
+});
diff --git a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap
index fcbeab2a164..3c7af9e6af4 100644
--- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap
@@ -2,6 +2,7 @@
exports[`/server public exports > should not include a breaking change 1`] = `
[
+ "PoppyCallbackPage",
"auth",
"buildClerkProps",
"clerkClient",
diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
index 3323bf74e9f..ddf28022b2b 100644
--- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
+++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
@@ -1,6 +1,7 @@
// There is no need to execute the complete authenticateRequest to test clerkMiddleware
// This mock SHOULD exist before the import of authenticateRequest
import { authenticatedMachineObject, AuthStatus, constants, TokenType } from '@clerk/backend/internal';
+import { ClerkAPIResponseError } from '@clerk/shared/error';
import { MalformedURLError } from '@clerk/shared/pathMatcher';
// used to assert the mock
import assert from 'assert';
@@ -11,6 +12,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, expectTypeOf, it, ty
import { clerkClient } from '../clerkClient';
import type { ClerkMiddlewareAuth } from '../clerkMiddleware';
import { clerkMiddleware } from '../clerkMiddleware';
+import { getSessionAuthDataFromRequest } from '../data/getAuthDataFromRequest';
import { createRouteMatcher } from '../routeMatcher';
import { decryptClerkRequestData } from '../utils';
@@ -1535,3 +1537,320 @@ describe('contentSecurityPolicy option', () => {
expect(requestCSP).toEqual(cspHeader);
});
});
+
+describe('Poppy middleware', () => {
+ const endpoint = 'https://company.example/poppy/browser-session';
+
+ it('returns the Poppy scope challenge without running the application or changing cookies', async () => {
+ authenticateRequestMock.mockRejectedValueOnce(
+ new ClerkAPIResponseError('Scope required', {
+ status: 403,
+ data: [{ code: 'insufficient_scope', message: 'Scope required' }],
+ }),
+ );
+ const handler = vi.fn();
+ const middleware = clerkMiddleware(handler, { __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ const response = await middleware(
+ mockRequest({
+ url: 'https://company.example/',
+ headers: { cookie: '__Host-poppy_session=browser-token; __session=native-session' },
+ }),
+ {} as NextFetchEvent,
+ );
+ assert(response instanceof Response);
+ expect(response.status).toBe(403);
+ expect(response.headers.get('www-authenticate')).toBe(
+ 'Bearer error="insufficient_scope", scope="poppy:browser-sign-in"',
+ );
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8');
+ expect(await response.text()).toBe('Additional authorization is required. Missing scope: poppy:browser-sign-in.');
+ expect(response.headers.getSetCookie()).toEqual([]);
+ expect(response.headers.has('x-middleware-set-cookie')).toBe(false);
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ { name: 'Poppy is disabled', enabled: false, status: 403, code: 'insufficient_scope' },
+ { name: 'a different forbidden error', enabled: true, status: 403, code: 'origin_invalid' },
+ { name: 'an infrastructure failure', enabled: true, status: 503, code: 'insufficient_scope' },
+ { name: 'an invalid token', enabled: true, status: 401, code: 'invalid_token' },
+ ])('propagates authentication errors unchanged for $name', async ({ enabled, status, code }) => {
+ const failure = new ClerkAPIResponseError('Authentication failed', {
+ status,
+ data: [{ code, message: 'Authentication failed' }],
+ });
+ authenticateRequestMock.mockRejectedValueOnce(failure);
+ const middleware = clerkMiddleware(enabled ? { __experimental_poppy: { browserSessionEndpoint: endpoint } } : {});
+ await expect(middleware(mockRequest({ url: 'https://company.example/' }), {} as NextFetchEvent)).rejects.toBe(
+ failure,
+ );
+ });
+
+ const metadata = {
+ protocol_version: '0.4',
+ organization: { name: 'Company', domain: 'company.example' },
+ auth: {
+ issuer: 'https://clerk.company.example',
+ scopes: { 'poppy:browser-sign-in': 'Full website access', profile: 'Read profile' },
+ },
+ web: { browser_session_endpoint: endpoint, unscoped: true },
+ extension: { unchanged: true },
+ };
+ const { web: _web, ...partialMetadata } = metadata;
+
+ it.each(['/.well-known/poppy.json', '/.well-known/oauth-protected-resource'])(
+ 'serves %s from the Backend API artifact before authentication',
+ async path => {
+ const getMetadata = vi.fn().mockResolvedValue(partialMetadata);
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }),
+ );
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ const response = await middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent);
+ assert(response instanceof Response);
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual(
+ path === '/.well-known/poppy.json'
+ ? metadata
+ : {
+ resource: 'https://company.example',
+ authorization_servers: ['https://clerk.company.example'],
+ scopes_supported: ['poppy:browser-sign-in', 'profile'],
+ bearer_methods_supported: ['header'],
+ },
+ );
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ expect(getMetadata).toHaveBeenCalledExactlyOnceWith();
+ expect(authenticateRequestMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([
+ { path: '/.well-known/poppy.json', method: 'POST' },
+ { path: '/.well-known/poppy.json', method: 'HEAD' },
+ { path: '/.well-known/oauth-protected-resource', method: 'POST' },
+ { path: '/.well-known/oauth-protected-resource', method: 'OPTIONS' },
+ ])('rejects discovery $method $path without fetching the artifact', async ({ path, method }) => {
+ const getMetadata = vi.fn();
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }),
+ );
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ const response = await middleware(
+ mockRequest({ url: 'https://company.example' + path, method }),
+ {} as NextFetchEvent,
+ );
+ assert(response instanceof Response);
+ expect(response.status).toBe(405);
+ expect(response.headers.get('allow')).toBe('GET');
+ expect(getMetadata).not.toHaveBeenCalled();
+ expect(authenticateRequestMock).not.toHaveBeenCalled();
+ });
+
+ it.each(['/.well-known/poppy.json', '/.well-known/oauth-protected-resource'])(
+ 'keeps %s available to the application when Poppy is off',
+ async path => {
+ const getMetadata = vi.fn();
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }),
+ );
+ const handler = vi.fn().mockResolvedValue(new Response('application discovery'));
+ const middleware = clerkMiddleware(handler);
+ const response = await middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent);
+ assert(response instanceof Response);
+ expect(await response.text()).toBe('application discovery');
+ expect(getMetadata).not.toHaveBeenCalled();
+ expect(handler).toHaveBeenCalledOnce();
+ expect(authenticateRequestMock).toHaveBeenCalledOnce();
+ },
+ );
+
+ it('requires the configured Company origin for discovery', async () => {
+ const getMetadata = vi.fn();
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }),
+ );
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ await middleware(mockRequest({ url: 'https://other.example/.well-known/poppy.json' }), {} as NextFetchEvent);
+ expect(getMetadata).not.toHaveBeenCalled();
+ expect(authenticateRequestMock).toHaveBeenCalledOnce();
+ });
+
+ it('serves discovery for the configured Company host behind a reverse proxy', async () => {
+ const getMetadata = vi.fn().mockResolvedValue(partialMetadata);
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }),
+ );
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ const response = await middleware(
+ mockRequest({
+ url: 'http://localhost:3000/.well-known/poppy.json',
+ headers: { host: 'company.example', 'x-forwarded-proto': 'https' },
+ }),
+ {} as NextFetchEvent,
+ );
+ assert(response instanceof Response);
+ expect(await response.json()).toEqual(metadata);
+ expect(getMetadata).toHaveBeenCalledExactlyOnceWith();
+ expect(authenticateRequestMock).not.toHaveBeenCalled();
+ });
+
+ it('rejects discovery when the configured endpoint differs from the artifact origin', async () => {
+ const getMetadata = vi.fn().mockResolvedValue({
+ ...partialMetadata,
+ organization: { ...partialMetadata.organization, domain: 'other.example' },
+ });
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }),
+ );
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ await expect(
+ middleware(mockRequest({ url: 'https://company.example/.well-known/poppy.json' }), {} as NextFetchEvent),
+ ).rejects.toThrow('configured Company origin');
+ });
+
+ it.each(['/.well-known/poppy.json', '/.well-known/oauth-protected-resource'])(
+ 'propagates an artifact failure for %s without fabricated discovery',
+ async path => {
+ const failure = new Error('Backend artifact unavailable');
+ const getMetadata = vi.fn().mockRejectedValue(failure);
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }),
+ );
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ await expect(
+ middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent),
+ ).rejects.toBe(failure);
+ expect(authenticateRequestMock).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([
+ { path: '', method: 'GET' },
+ { path: '', method: 'POST' },
+ { path: '/authorize', method: 'POST' },
+ ])('forwards protocol $method $path before native authentication', async ({ path, method }) => {
+ const forwarded = new Response('protocol-response', { status: 400, headers: { 'Poppy-Version': '0.4' } });
+ const proxyBrowserRequest = vi.fn().mockResolvedValue(forwarded);
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { proxyBrowserRequest } }),
+ );
+ const request = mockRequest({ url: endpoint + path, method });
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ const response = await middleware(request, {} as NextFetchEvent);
+ expect(response).toBe(forwarded);
+ expect(proxyBrowserRequest).toHaveBeenCalledWith(request, { browserSessionEndpoint: endpoint });
+ expect(authenticateRequestMock).not.toHaveBeenCalled();
+ });
+
+ it('recognizes the configured Company host behind a reverse proxy', async () => {
+ const forwarded = new Response('protocol-response');
+ const proxyBrowserRequest = vi.fn().mockResolvedValue(forwarded);
+ vi.mocked(clerkClient).mockResolvedValue(
+ Object.assign(await clerkClient(), { __experimental_poppy: { proxyBrowserRequest } }),
+ );
+ const request = mockRequest({
+ url: 'http://localhost:3000/poppy/browser-session',
+ headers: { host: 'company.example', 'x-forwarded-proto': 'https' },
+ });
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ expect(await middleware(request, {} as NextFetchEvent)).toBe(forwarded);
+ expect(authenticateRequestMock).not.toHaveBeenCalled();
+ });
+
+ it('clears only the ticket response cookie and renders the callback without a native handshake', async () => {
+ const request = mockRequest({
+ url: endpoint + '/callback',
+ headers: { cookie: '__Host-poppy_ticket=one-use-ticket; __session=prior-session' },
+ });
+ const middleware = clerkMiddleware({
+ __experimental_poppy: { browserSessionEndpoint: endpoint, redirectTo: '/cart' },
+ contentSecurityPolicy: { strict: true },
+ });
+ const response = await middleware(request, {} as NextFetchEvent);
+ assert(response instanceof Response);
+ expect(authenticateRequestMock).not.toHaveBeenCalled();
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ expect(response.headers.get('content-security-policy')).toContain("'strict-dynamic'");
+ expect(response.headers.get('x-middleware-request-content-security-policy')).toBe(
+ response.headers.get('content-security-policy'),
+ );
+ expect(response.headers.get('x-middleware-request-x-nonce')).toBe(response.headers.get('x-nonce'));
+ expect(response.headers.get('x-nonce')).toBeTruthy();
+ expect(response.headers.has('x-middleware-set-cookie')).toBe(false);
+ expect(response.headers.getSetCookie()).toHaveLength(1);
+ expect(response.headers.getSetCookie()[0]).toContain('__Host-poppy_ticket=;');
+ expect(response.headers.getSetCookie()[0]).toContain('Max-Age=0');
+ expect(response.headers.get('x-middleware-request-cookie')).toContain('__Host-poppy_ticket=one-use-ticket');
+ const data = decryptClerkRequestData(
+ response.headers.get('x-middleware-request-' + constants.Headers.ClerkRequestData),
+ );
+ expect(data.__experimental_poppy).toEqual({ browserSessionEndpoint: endpoint, redirectTo: '/cart' });
+ expect(data.secretKey).toBeUndefined();
+ });
+
+ it.each([true, false])('exposes verified identity only on an authenticated agent; signed in=%s', async signedIn => {
+ const identity = {
+ session_id: 'protocol-session',
+ client_id: 'https://agent.example',
+ user_id: 'pseudonym',
+ ...(signedIn
+ ? { account: { user_id: 'user_123', session_id: 'sess_123', scopes: ['poppy:browser-sign-in'] } }
+ : {}),
+ };
+ const token = [
+ JSON.stringify({ alg: 'RS256', typ: 'JWT' }),
+ JSON.stringify({ sub: 'user_123', sid: 'sess_123', agt: { id: identity.client_id }, iat: 0, exp: 9999999999 }),
+ 'signature',
+ ]
+ .map(part => Buffer.from(part).toString('base64url'))
+ .join('.');
+ authenticateRequestMock.mockResolvedValueOnce({
+ status: signedIn ? AuthStatus.SignedIn : AuthStatus.SignedOut,
+ token: signedIn ? token : null,
+ tokenType: TokenType.SessionToken,
+ poppy: identity,
+ toAuth: () => ({
+ tokenType: TokenType.SessionToken,
+ isAuthenticated: signedIn,
+ userId: signedIn ? 'user_123' : null,
+ debug: vi.fn(),
+ }),
+ headers: new Headers(),
+ publishableKey,
+ });
+ const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } });
+ const response = await middleware(mockRequest({ url: 'https://company.example/cart' }), {} as NextFetchEvent);
+ assert(response instanceof Response);
+ const data = decryptClerkRequestData(
+ response.headers.get('x-middleware-request-' + constants.Headers.ClerkRequestData),
+ );
+ expect(data.poppySession).toEqual(identity);
+ const serverHeaders = new Headers();
+ response.headers.forEach((value, key) => {
+ if (key.startsWith('x-middleware-request-')) {
+ serverHeaders.set(key.slice('x-middleware-request-'.length), value);
+ }
+ });
+ const appAuth = getSessionAuthDataFromRequest(
+ new NextRequest('https://company.example/cart', { headers: serverHeaders }),
+ );
+ expect(appAuth.isAuthenticated).toBe(signedIn);
+ expect(Object.keys(appAuth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]);
+ if (signedIn) {
+ expect(appAuth.agent).toEqual({
+ type: 'agent',
+ sub: identity.client_id,
+ __experimental_poppyClientId: identity.client_id,
+ __experimental_poppyUserId: identity.user_id,
+ __experimental_poppySessionId: identity.session_id,
+ });
+ expectTypeOf(appAuth.agent?.__experimental_poppySessionId).toEqualTypeOf();
+ } else {
+ expect(appAuth).not.toHaveProperty('agent');
+ }
+ expect(appAuth.userId).toBe(signedIn ? 'user_123' : null);
+ expect(appAuth).not.toHaveProperty('poppy');
+ });
+});
diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts
index a0e0fb9ceeb..cef54ac660f 100644
--- a/packages/nextjs/src/server/clerkMiddleware.ts
+++ b/packages/nextjs/src/server/clerkMiddleware.ts
@@ -1,4 +1,5 @@
import type { AuthObject, ClerkClient } from '@clerk/backend';
+import { createPoppyBrowserSessionHandler, poppyCookies } from '@clerk/backend';
import type {
AuthenticatedState,
AuthenticateRequestOptions,
@@ -20,6 +21,7 @@ import {
TokenType,
} from '@clerk/backend/internal';
import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy';
+import { isClerkAPIResponseError } from '@clerk/shared/error';
import { isProductionFromPublishableKey, parsePublishableKey } from '@clerk/shared/keys';
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
import { isMalformedURLError } from '@clerk/shared/pathMatcher';
@@ -61,6 +63,7 @@ import type {
import {
assertKey,
decorateRequest,
+ encryptClerkRequestData,
handleMultiDomainAndProxy,
redirectAdapter,
setRequestHeadersOnNextResponse,
@@ -206,6 +209,79 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
return clerkMiddlewareRequestDataStorage.run(requestDataStore, async () => {
const resolvedClerkClient = await clerkClient();
+ if (options.__experimental_poppy) {
+ const proxyPoppy = createPoppyBrowserSessionHandler({
+ ...options.__experimental_poppy,
+ clerkClient: resolvedClerkClient,
+ });
+ const endpoint = new URL(options.__experimental_poppy.browserSessionEndpoint);
+ const poppyRequestUrl = createClerkRequest(request).clerkUrl;
+ if (poppyRequestUrl.origin === endpoint.origin) {
+ if (
+ poppyRequestUrl.pathname === '/.well-known/poppy.json' ||
+ poppyRequestUrl.pathname === '/.well-known/oauth-protected-resource'
+ ) {
+ if (request.method !== 'GET') {
+ return new Response(null, { status: 405, headers: { Allow: 'GET' } });
+ }
+ const metadata = await resolvedClerkClient.__experimental_poppy.getMetadata();
+ if (endpoint.origin !== `https://${metadata.organization.domain}`) {
+ throw new Error('The browser Session endpoint must use the configured Company origin.');
+ }
+ return Response.json(
+ poppyRequestUrl.pathname === '/.well-known/poppy.json'
+ ? { ...metadata, web: { browser_session_endpoint: endpoint.href, unscoped: true } }
+ : {
+ resource: endpoint.origin,
+ authorization_servers: [metadata.auth.issuer],
+ scopes_supported: Object.keys(metadata.auth.scopes),
+ bearer_methods_supported: ['header'],
+ },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ }
+ if (
+ (poppyRequestUrl.pathname === endpoint.pathname &&
+ (request.method === 'GET' || request.method === 'POST')) ||
+ (poppyRequestUrl.pathname === endpoint.pathname + '/authorize' && request.method === 'POST')
+ ) {
+ return proxyPoppy(request);
+ }
+ if (poppyRequestUrl.pathname === endpoint.pathname + '/callback' && request.method === 'GET') {
+ const response = NextResponse.next();
+ if (options.contentSecurityPolicy) {
+ const { headers } = createContentSecurityPolicyHeaders(
+ (parsePublishableKey(publishableKey)?.frontendApi ?? '').replace('$', ''),
+ options.contentSecurityPolicy,
+ );
+ const cspRequestHeaders: Record = {};
+ headers.forEach(([key, value]) => {
+ setHeader(response, key, value);
+ cspRequestHeaders[key] = value;
+ });
+ setRequestHeadersOnNextResponse(response, request, cspRequestHeaders);
+ }
+ response.headers.set('Cache-Control', 'no-store');
+ response.headers.set('Referrer-Policy', 'no-referrer');
+ response.headers.set('X-Frame-Options', 'DENY');
+ response.headers.append(
+ 'Set-Cookie',
+ `${poppyCookies.ticket}=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict`,
+ );
+ setRequestHeadersOnNextResponse(response, request, {
+ [constants.Headers.AuthStatus]: AuthStatus.SignedOut,
+ [constants.Headers.AuthToken]: '',
+ [constants.Headers.AuthSignature]: '',
+ [constants.Headers.AuthReason]: '',
+ [constants.Headers.AuthMessage]: '',
+ [constants.Headers.ClerkUrl]: poppyRequestUrl.href,
+ [constants.Headers.ClerkRequestData]: encryptClerkRequestData(resolvedParams, {}, null) ?? '',
+ });
+ return response;
+ }
+ }
+ }
+
if (options.debug) {
logger.enable();
}
@@ -225,10 +301,29 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
}));
}
- const requestState = await resolvedClerkClient.authenticateRequest(
- clerkRequest,
- createAuthenticateRequestOptions(clerkRequest, options),
- );
+ const requestState = await resolvedClerkClient
+ .authenticateRequest(clerkRequest, createAuthenticateRequestOptions(clerkRequest, options))
+ .catch((error: unknown) => {
+ if (
+ !options.__experimental_poppy ||
+ !isClerkAPIResponseError(error) ||
+ error.status !== 403 ||
+ error.errors[0]?.code !== 'insufficient_scope'
+ ) {
+ throw error;
+ }
+ return new NextResponse('Additional authorization is required. Missing scope: poppy:browser-sign-in.', {
+ status: 403,
+ headers: {
+ 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="poppy:browser-sign-in"',
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'text/plain; charset=utf-8',
+ },
+ });
+ });
+ if (requestState instanceof NextResponse) {
+ return requestState;
+ }
return runHandlerWithRequestState({
clerkRequest,
diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts
index 348e7f277e2..0656d610b29 100644
--- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts
+++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts
@@ -60,6 +60,7 @@ const createAuthOptions = (req: RequestLike, opts: GetAuthDataFromRequestOptions
authMessage: getAuthKeyFromRequest(req, 'AuthMessage'),
authReason: getAuthKeyFromRequest(req, 'AuthReason'),
treatPendingAsSignedOut,
+ poppySession: decryptedRequestData.poppySession,
};
};
diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts
index 477ad2eac59..0dde4a959b2 100644
--- a/packages/nextjs/src/server/index.ts
+++ b/packages/nextjs/src/server/index.ts
@@ -51,6 +51,8 @@ export type { ClerkMiddlewareAuth, ClerkMiddlewareSessionAuthObject, ClerkMiddle
*/
export type {
OrganizationMembershipRole,
+ PoppyBrowserSession,
+ PoppyOptions,
// Resources
AllowlistIdentifier,
Client,
@@ -85,3 +87,5 @@ export {
type FrontendApiProxyOptions,
type NextFrontendApiProxyOptions,
} from './proxy';
+
+export { PoppyCallbackPage } from './PoppyCallbackPage';
diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts
index e6504478823..62f297331d0 100644
--- a/packages/nextjs/src/server/utils.ts
+++ b/packages/nextjs/src/server/utils.ts
@@ -1,4 +1,4 @@
-import type { AuthObject } from '@clerk/backend';
+import type { AuthObject, PoppyBrowserSession } from '@clerk/backend';
import type { AuthenticateRequestOptions, ClerkRequest, RequestState } from '@clerk/backend/internal';
import { constants } from '@clerk/backend/internal';
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
@@ -89,7 +89,7 @@ export function decorateRequest(
}
if (rewriteURL) {
- const clerkRequestData = encryptClerkRequestData(requestData, keylessMode, machineAuthObject);
+ const clerkRequestData = encryptClerkRequestData(requestData, keylessMode, machineAuthObject, requestState.poppy);
setRequestHeadersOnNextResponse(res, req, {
[constants.Headers.AuthStatus]: status,
@@ -210,6 +210,7 @@ export function encryptClerkRequestData(
requestData: Partial,
keylessModeKeys: Pick,
machineAuthObject: AuthObject | null,
+ poppySession?: PoppyBrowserSession | null,
) {
const isEmpty = (obj: Record | undefined) => {
if (!obj) {
@@ -218,7 +219,7 @@ export function encryptClerkRequestData(
return !Object.values(obj).some(v => v !== undefined);
};
- if (isEmpty(requestData) && isEmpty(keylessModeKeys) && !machineAuthObject) {
+ if (isEmpty(requestData) && isEmpty(keylessModeKeys) && !machineAuthObject && !poppySession) {
return;
}
@@ -231,7 +232,12 @@ export function encryptClerkRequestData(
: ENCRYPTION_KEY || SECRET_KEY || KEYLESS_ENCRYPTION_KEY;
return AES.encrypt(
- JSON.stringify({ ...keylessModeKeys, ...requestData, machineAuthObject: machineAuthObject ?? undefined }),
+ JSON.stringify({
+ ...keylessModeKeys,
+ ...requestData,
+ machineAuthObject: machineAuthObject ?? undefined,
+ poppySession,
+ }),
maybeKeylessEncryptionKey,
).toString();
}
@@ -242,7 +248,7 @@ export function encryptClerkRequestData(
*/
export function decryptClerkRequestData(
encryptedRequestData?: string | undefined | null,
-): Partial & { machineAuthObject?: AuthObject } {
+): Partial & { machineAuthObject?: AuthObject; poppySession?: PoppyBrowserSession | null } {
if (!encryptedRequestData) {
return {};
}