From ab8a8e5ea7f8a27b0872fb69e15660049a00e10b Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Fri, 6 Nov 2026 16:07:08 -0500 Subject: [PATCH 1/5] feat(backend): add Poppy browser session helpers --- .changeset/poppy-browser-session-v04.md | 12 + .../backend/src/__tests__/exports.test.ts | 2 + .../src/api/__tests__/PoppyApi.test.ts | 170 +++++++++ .../backend/src/api/endpoints/PoppyApi.ts | 96 +++++ packages/backend/src/api/factory.ts | 9 + packages/backend/src/index.ts | 4 + .../src/jwt/__tests__/assertions.test.ts | 12 +- .../src/jwt/__tests__/verifyJwt.test.ts | 34 ++ packages/backend/src/jwt/algorithms.ts | 28 +- packages/backend/src/jwt/cryptoKeys.ts | 2 +- packages/backend/src/poppyBrowserSession.ts | 26 ++ .../src/tokens/__tests__/authObjects.test.ts | 137 ++++++- .../src/tokens/__tests__/request.test.ts | 356 ++++++++++++++++++ packages/backend/src/tokens/authObjects.ts | 33 ++ packages/backend/src/tokens/authStatus.ts | 9 +- .../backend/src/tokens/authenticateContext.ts | 2 + packages/backend/src/tokens/handshake.ts | 2 +- packages/backend/src/tokens/request.ts | 149 +++++++- packages/backend/src/tokens/types.ts | 7 + packages/backend/src/tokens/verify.ts | 4 +- .../src/app-router/client/PoppyCallback.tsx | 31 ++ .../client/__tests__/PoppyCallback.test.tsx | 70 ++++ .../nextjs/src/server/PoppyCallbackPage.tsx | 31 ++ .../__tests__/PoppyCallbackPage.test.tsx | 40 ++ .../__snapshots__/exports.test.ts.snap | 1 + .../server/__tests__/clerkMiddleware.test.ts | 319 ++++++++++++++++ packages/nextjs/src/server/clerkMiddleware.ts | 103 ++++- .../src/server/data/getAuthDataFromRequest.ts | 1 + packages/nextjs/src/server/index.ts | 4 + packages/nextjs/src/server/utils.ts | 16 +- 30 files changed, 1656 insertions(+), 54 deletions(-) create mode 100644 .changeset/poppy-browser-session-v04.md create mode 100644 packages/backend/src/api/__tests__/PoppyApi.test.ts create mode 100644 packages/backend/src/api/endpoints/PoppyApi.ts create mode 100644 packages/backend/src/poppyBrowserSession.ts create mode 100644 packages/nextjs/src/app-router/client/PoppyCallback.tsx create mode 100644 packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx create mode 100644 packages/nextjs/src/server/PoppyCallbackPage.tsx create mode 100644 packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx 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: '
Connect
' }, + { 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 1e8fd671948..127be7ce952 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 5dc9b3a2c2f..5ceb0e234b1 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -80,7 +80,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 bd0a74895f8..7a90cfc86f7 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; @@ -896,18 +900,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 @@ -917,14 +919,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 1ed485cd71d..6be42d89d12 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -136,10 +136,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 {}; } From 2a15e32d63bbe67548d062342c70fa59204ada6c Mon Sep 17 00:00:00 2001 From: Kevin Wang <26389321+thiskevinwang@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:37:50 -0500 Subject: [PATCH 2/5] feat(nextjs): use FAPI-hosted Poppy browser joins (#10747) --- .changeset/poppy-browser-session-v04.md | 6 +- .changeset/poppy-server-handshake.md | 6 + .../backend/src/__tests__/exports.test.ts | 2 - .../src/api/__tests__/PoppyApi.test.ts | 113 +------ .../backend/src/api/endpoints/PoppyApi.ts | 59 +--- packages/backend/src/api/factory.ts | 2 +- packages/backend/src/index.ts | 4 +- packages/backend/src/poppyBrowserSession.ts | 26 -- .../src/tokens/__tests__/request.test.ts | 319 ++---------------- packages/backend/src/tokens/request.ts | 81 +---- packages/backend/src/tokens/types.ts | 9 +- .../src/app-router/client/PoppyCallback.tsx | 31 -- .../client/__tests__/PoppyCallback.test.tsx | 70 ---- .../nextjs/src/server/PoppyCallbackPage.tsx | 31 -- .../__tests__/PoppyCallbackPage.test.tsx | 40 --- .../server/__tests__/clerkMiddleware.test.ts | 104 +----- packages/nextjs/src/server/clerkMiddleware.ts | 88 ++--- packages/nextjs/src/server/index.ts | 3 - 18 files changed, 81 insertions(+), 913 deletions(-) create mode 100644 .changeset/poppy-server-handshake.md delete mode 100644 packages/backend/src/poppyBrowserSession.ts delete mode 100644 packages/nextjs/src/app-router/client/PoppyCallback.tsx delete mode 100644 packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx delete mode 100644 packages/nextjs/src/server/PoppyCallbackPage.tsx delete mode 100644 packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx diff --git a/.changeset/poppy-browser-session-v04.md b/.changeset/poppy-browser-session-v04.md index daaa4a30e72..647eef4ad85 100644 --- a/.changeset/poppy-browser-session-v04.md +++ b/.changeset/poppy-browser-session-v04.md @@ -3,10 +3,10 @@ "@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. +Add Poppy 0.4 website browser Sessions. Enable `__experimental_poppy: true` on `clerkMiddleware()` 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 Backend SDK checks current OAuth authority for 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 live Session resolver and metadata helper 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/.changeset/poppy-server-handshake.md b/.changeset/poppy-server-handshake.md new file mode 100644 index 00000000000..dc12512aeec --- /dev/null +++ b/.changeset/poppy-server-handshake.md @@ -0,0 +1,6 @@ +--- +"@clerk/backend": patch +"@clerk/nextjs": patch +--- + +Publish discovery with `__experimental_poppy: true`. Clerk FAPI hosts the browser join, redeems its ticket server-side, and uses the existing Clerk handshake to set application cookies. Remove the app-domain join proxy and customer callback page. diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index acd013e5e6e..7892bf9f554 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -12,8 +12,6 @@ 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 index d3afd0d4b70..c67ae49e7b1 100644 --- a/packages/backend/src/api/__tests__/PoppyApi.test.ts +++ b/packages/backend/src/api/__tests__/PoppyApi.test.ts @@ -3,11 +3,9 @@ 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' }; @@ -55,116 +53,23 @@ describe('PoppyAPI', () => { await expect(poppy.getMetadata()).rejects.toMatchObject({ status }); }); - it.each([ - { method: 'GET', suffix: '', body: undefined, status: 200, responseBody: '
Connect
' }, - { 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 }) => { + it.each([{ type: 'session' as const, token: 'native-session-jwt' }])( + 'uses the unified resolver for $type credentials', + async credential => { 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', - }, - }); + 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 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"'); + const { __experimental_poppy: poppy } = createBackendApiClient({ publishableKey }); + await expect(poppy.resolveSession(credential)).resolves.toEqual(identity); }, ); - 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 index 823024a4bd7..7669be8ccdf 100644 --- a/packages/backend/src/api/endpoints/PoppyApi.ts +++ b/packages/backend/src/api/endpoints/PoppyApi.ts @@ -1,14 +1,5 @@ -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; @@ -22,28 +13,12 @@ export type PoppyMetadata = Record & { 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 type PoppyCredential = { type: 'session'; token: string }; export class PoppyAPI { constructor( private readonly request: RequestFunction, private readonly artifactRequest: RequestFunction, - private readonly frontendApi?: string, ) {} public async getMetadata() { @@ -61,36 +36,4 @@ export class PoppyAPI { 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 735b626c113..f420aca8cc0 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -115,7 +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), + __experimental_poppy: new PoppyAPI(poppyBrowserRequest, request), 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 fecd9adae6b..b285b092640 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -204,6 +204,4 @@ 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'; +export type { PoppyBrowserSession } from './api/endpoints/PoppyApi'; diff --git a/packages/backend/src/poppyBrowserSession.ts b/packages/backend/src/poppyBrowserSession.ts deleted file mode 100644 index b934fe87ff9..00000000000 --- a/packages/backend/src/poppyBrowserSession.ts +++ /dev/null @@ -1,26 +0,0 @@ -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__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 127be7ce952..34258aa63c1 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1,4 +1,3 @@ -import { getCookieSuffix } from '@clerk/shared/keys'; import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; @@ -512,302 +511,9 @@ describe('tokens.authenticateRequest(options)', () => { }); 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' } } }, @@ -846,7 +552,12 @@ describe('tokens.authenticateRequest(options)', () => { } }); - it('propagates an authority store outage', async () => { + it('propagates an authority store outage for a native agent Session', async () => { + const { data: jwt } = await signJwt( + { ...mockJwtPayload, agt: { id: identity.client_id, poppy: { session_id: identity.session_id } } }, + 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', () => @@ -855,14 +566,22 @@ describe('tokens.authenticateRequest(options)', () => { ); await expect( authenticateRequest( - mockRequestWithHeaderAuth({ cookie: `__Host-poppy_session=${browserToken}` }, 'https://example.com/path'), - mockOptions({ - apiClient: createBackendApiClient({ publishableKey: PK_TEST }), - __experimental_poppy: { browserSessionEndpoint: endpoint }, - }), + mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }), + mockOptions({ apiClient: createBackendApiClient({ publishableKey: PK_TEST }) }), ), ).rejects.toMatchObject({ status: 503 }); }); + + it('does not interpret an application cookie as a Poppy credential', async () => { + server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + const state = await authenticateRequest( + mockRequestWithHeaderAuth({ cookie: '__Host-poppy_session=untrusted' }), + mockOptions({ apiClient: createBackendApiClient({ publishableKey: PK_TEST }), __experimental_poppy: true }), + ); + expect(state.isAuthenticated).toBe(true); + expect(state.poppy).toBeFalsy(); + expect(state.toAuth()).not.toHaveProperty('agent'); + }); }); // diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 7a90cfc86f7..4721023739f 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -3,7 +3,6 @@ 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'; @@ -951,74 +950,6 @@ export const authenticateRequest: AuthenticateRequest = (async ( } }; - 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; - } - if (!state.isAuthenticated) { return state; } @@ -1026,23 +957,17 @@ export const authenticateRequest: AuthenticateRequest = (async ( 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 }); + const current = 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; - } + authenticateContext.poppySession = current; + state.poppy = current; return state; }) as AuthenticateRequest; diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index aafde10a6ec..aa85cf06193 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -2,7 +2,6 @@ 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, @@ -19,11 +18,11 @@ import type { VerifyTokenOptions } from './verify'; */ 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. + * Clerk's preview flag for publishing Poppy discovery through `clerkMiddleware()`. + * The Backend API supplies Clerk's hosted browser Session endpoint. No application join or callback route is needed. + * This option may change while Poppy is a draft. */ - __experimental_poppy?: PoppyOptions; + __experimental_poppy?: boolean; /** * The Clerk Publishable Key from the [**API keys**](https://dashboard.clerk.com/~/api-keys) page in the Clerk Dashboard. */ diff --git a/packages/nextjs/src/app-router/client/PoppyCallback.tsx b/packages/nextjs/src/app-router/client/PoppyCallback.tsx deleted file mode 100644 index 357df87b310..00000000000 --- a/packages/nextjs/src/app-router/client/PoppyCallback.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'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 deleted file mode 100644 index 083a6e4cb72..00000000000 --- a/packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 13df50eaca4..00000000000 --- a/packages/nextjs/src/server/PoppyCallbackPage.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index b42e0f6dc8d..00000000000 --- a/packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index ddf28022b2b..ea4d2e7d400 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1539,7 +1539,7 @@ describe('contentSecurityPolicy option', () => { }); describe('Poppy middleware', () => { - const endpoint = 'https://company.example/poppy/browser-session'; + const endpoint = 'https://clerk.company.example/v1/poppy/browser_sessions'; it('returns the Poppy scope challenge without running the application or changing cookies', async () => { authenticateRequestMock.mockRejectedValueOnce( @@ -1549,7 +1549,7 @@ describe('Poppy middleware', () => { }), ); const handler = vi.fn(); - const middleware = clerkMiddleware(handler, { __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const middleware = clerkMiddleware(handler, { __experimental_poppy: true }); const response = await middleware( mockRequest({ url: 'https://company.example/', @@ -1581,7 +1581,7 @@ describe('Poppy middleware', () => { data: [{ code, message: 'Authentication failed' }], }); authenticateRequestMock.mockRejectedValueOnce(failure); - const middleware = clerkMiddleware(enabled ? { __experimental_poppy: { browserSessionEndpoint: endpoint } } : {}); + const middleware = clerkMiddleware(enabled ? { __experimental_poppy: true } : {}); await expect(middleware(mockRequest({ url: 'https://company.example/' }), {} as NextFetchEvent)).rejects.toBe( failure, ); @@ -1597,16 +1597,15 @@ describe('Poppy middleware', () => { 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); + const getMetadata = vi.fn().mockResolvedValue(metadata); vi.mocked(clerkClient).mockResolvedValue( Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), ); - const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const middleware = clerkMiddleware({ __experimental_poppy: true }); const response = await middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent); assert(response instanceof Response); expect(response.status).toBe(200); @@ -1636,7 +1635,7 @@ describe('Poppy middleware', () => { vi.mocked(clerkClient).mockResolvedValue( Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), ); - const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const middleware = clerkMiddleware({ __experimental_poppy: true }); const response = await middleware( mockRequest({ url: 'https://company.example' + path, method }), {} as NextFetchEvent, @@ -1666,23 +1665,12 @@ describe('Poppy middleware', () => { }, ); - 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); + const getMetadata = vi.fn().mockResolvedValue(metadata); vi.mocked(clerkClient).mockResolvedValue( Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), ); - const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const middleware = clerkMiddleware({ __experimental_poppy: true }); const response = await middleware( mockRequest({ url: 'http://localhost:3000/.well-known/poppy.json', @@ -1696,15 +1684,15 @@ describe('Poppy middleware', () => { expect(authenticateRequestMock).not.toHaveBeenCalled(); }); - it('rejects discovery when the configured endpoint differs from the artifact origin', async () => { + it('rejects discovery on a different Company origin', async () => { const getMetadata = vi.fn().mockResolvedValue({ - ...partialMetadata, - organization: { ...partialMetadata.organization, domain: 'other.example' }, + ...metadata, + organization: { ...metadata.organization, domain: 'other.example' }, }); vi.mocked(clerkClient).mockResolvedValue( Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), ); - const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const middleware = clerkMiddleware({ __experimental_poppy: true }); await expect( middleware(mockRequest({ url: 'https://company.example/.well-known/poppy.json' }), {} as NextFetchEvent), ).rejects.toThrow('configured Company origin'); @@ -1718,7 +1706,7 @@ describe('Poppy middleware', () => { vi.mocked(clerkClient).mockResolvedValue( Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), ); - const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const middleware = clerkMiddleware({ __experimental_poppy: true }); await expect( middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent), ).rejects.toBe(failure); @@ -1726,70 +1714,6 @@ describe('Poppy middleware', () => { }, ); - 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', @@ -1820,7 +1744,7 @@ describe('Poppy middleware', () => { headers: new Headers(), publishableKey, }); - const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const middleware = clerkMiddleware({ __experimental_poppy: true }); const response = await middleware(mockRequest({ url: 'https://company.example/cart' }), {} as NextFetchEvent); assert(response instanceof Response); const data = decryptClerkRequestData( diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index cef54ac660f..ea9c1c9b287 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,5 +1,4 @@ import type { AuthObject, ClerkClient } from '@clerk/backend'; -import { createPoppyBrowserSessionHandler, poppyCookies } from '@clerk/backend'; import type { AuthenticatedState, AuthenticateRequestOptions, @@ -63,7 +62,6 @@ import type { import { assertKey, decorateRequest, - encryptClerkRequestData, handleMultiDomainAndProxy, redirectAdapter, setRequestHeadersOnNextResponse, @@ -210,75 +208,29 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl 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 === '/.well-known/poppy.json' || + poppyRequestUrl.pathname === '/.well-known/oauth-protected-resource' + ) { + if (request.method !== 'GET') { + return new Response(null, { status: 405, headers: { Allow: 'GET' } }); } - 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; + const metadata = await resolvedClerkClient.__experimental_poppy.getMetadata(); + if (poppyRequestUrl.origin !== `https://${metadata.organization.domain}`) { + throw new Error('Poppy discovery must use the configured Company origin.'); } + return Response.json( + poppyRequestUrl.pathname === '/.well-known/poppy.json' + ? metadata + : { + resource: poppyRequestUrl.origin, + authorization_servers: [metadata.auth.issuer], + scopes_supported: Object.keys(metadata.auth.scopes), + bearer_methods_supported: ['header'], + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); } } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 0dde4a959b2..48c39320849 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -52,7 +52,6 @@ export type { ClerkMiddlewareAuth, ClerkMiddlewareSessionAuthObject, ClerkMiddle export type { OrganizationMembershipRole, PoppyBrowserSession, - PoppyOptions, // Resources AllowlistIdentifier, Client, @@ -87,5 +86,3 @@ export { type FrontendApiProxyOptions, type NextFrontendApiProxyOptions, } from './proxy'; - -export { PoppyCallbackPage } from './PoppyCallbackPage'; From 0e44847293b04f77a1b783a50310febc577675a2 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 10 Nov 2026 15:18:24 -0500 Subject: [PATCH 3/5] fix(backend): use request configuration for Poppy authority checks --- .../backend/src/api/__tests__/PoppyApi.test.ts | 13 ++----------- packages/backend/src/api/endpoints/PoppyApi.ts | 14 ++++++++++---- packages/backend/src/api/factory.ts | 9 +-------- .../backend/src/tokens/__tests__/request.test.ts | 6 +++--- packages/backend/src/tokens/request.ts | 2 +- 5 files changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/backend/src/api/__tests__/PoppyApi.test.ts b/packages/backend/src/api/__tests__/PoppyApi.test.ts index c67ae49e7b1..0ba47adbd38 100644 --- a/packages/backend/src/api/__tests__/PoppyApi.test.ts +++ b/packages/backend/src/api/__tests__/PoppyApi.test.ts @@ -1,4 +1,3 @@ -import { buildPublishableKey } from '@clerk/shared/keys'; import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; @@ -7,7 +6,6 @@ import { createBackendApiClient } from '../factory'; describe('PoppyAPI', () => { 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 () => { @@ -26,7 +24,6 @@ describe('PoppyAPI', () => { ); const { __experimental_poppy: poppy } = createBackendApiClient({ apiUrl: 'https://api.clerk.test', - publishableKey, secretKey: 'sk_live_example', }); await expect(poppy.getMetadata()).resolves.toEqual(metadata); @@ -47,7 +44,6 @@ describe('PoppyAPI', () => { ); const { __experimental_poppy: poppy } = createBackendApiClient({ apiUrl: 'https://api.clerk.test', - publishableKey, secretKey: 'sk_live_example', }); await expect(poppy.getMetadata()).rejects.toMatchObject({ status }); @@ -63,13 +59,8 @@ describe('PoppyAPI', () => { return HttpResponse.json(identity); }), ); - const { __experimental_poppy: poppy } = createBackendApiClient({ publishableKey }); - await expect(poppy.resolveSession(credential)).resolves.toEqual(identity); + const { __experimental_poppy: poppy } = createBackendApiClient({}); + await expect(poppy.resolveSession(credential, frontendApi)).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'); - }); }); diff --git a/packages/backend/src/api/endpoints/PoppyApi.ts b/packages/backend/src/api/endpoints/PoppyApi.ts index 7669be8ccdf..9ca36bb837e 100644 --- a/packages/backend/src/api/endpoints/PoppyApi.ts +++ b/packages/backend/src/api/endpoints/PoppyApi.ts @@ -1,4 +1,5 @@ import type { RequestFunction } from '../request'; +import { buildRequest } from '../request'; export type PoppyBrowserSession = { session_id: string; @@ -18,19 +19,24 @@ export type PoppyCredential = { type: 'session'; token: string }; export class PoppyAPI { constructor( private readonly request: RequestFunction, - private readonly artifactRequest: RequestFunction, + private readonly userAgent?: string, ) {} public async getMetadata() { - const artifact = await this.artifactRequest<{ object: 'artifact'; data: PoppyMetadata }>({ + const artifact = await this.request<{ object: 'artifact'; data: PoppyMetadata }>({ method: 'GET', path: '/artifacts/well-known-poppy', }); return artifact.data; } - public resolveSession(credential: PoppyCredential) { - return this.request({ + public resolveSession(credential: PoppyCredential, frontendApi: string) { + const request = buildRequest({ + apiUrl: frontendApi.startsWith('http') ? frontendApi : `https://${frontendApi}`, + requireSecretKey: false, + userAgent: this.userAgent, + }); + return request({ method: 'POST', path: '/poppy/browser_sessions/resolve', bodyParams: { credential }, diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index f420aca8cc0..3351a27f0d9 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -1,5 +1,3 @@ -import { parsePublishableKey } from '@clerk/shared/keys'; - import { AccountlessApplicationAPI, ActorTokenAPI, @@ -43,17 +41,12 @@ 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( @@ -115,7 +108,7 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { organizations: new OrganizationAPI(request), organizationPermissions: new OrganizationPermissionAPI(request), organizationRoles: new OrganizationRoleAPI(request), - __experimental_poppy: new PoppyAPI(poppyBrowserRequest, request), + __experimental_poppy: new PoppyAPI(request, options.userAgent), phoneNumbers: new PhoneNumberAPI(request), proxyChecks: new ProxyCheckAPI(request), redirectUrls: new RedirectUrlAPI(request), diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 34258aa63c1..824517996cf 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -532,7 +532,7 @@ describe('tokens.authenticateRequest(options)', () => { ); const state = await authenticateRequest( mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }), - mockOptions({ apiClient: createBackendApiClient({ publishableKey: PK_TEST }) }), + mockOptions({ apiClient: createBackendApiClient({}) }), ); expect(state.isAuthenticated).toBe(active); const auth = state.toAuth(); @@ -567,7 +567,7 @@ describe('tokens.authenticateRequest(options)', () => { await expect( authenticateRequest( mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }), - mockOptions({ apiClient: createBackendApiClient({ publishableKey: PK_TEST }) }), + mockOptions({ apiClient: createBackendApiClient({}) }), ), ).rejects.toMatchObject({ status: 503 }); }); @@ -576,7 +576,7 @@ describe('tokens.authenticateRequest(options)', () => { server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); const state = await authenticateRequest( mockRequestWithHeaderAuth({ cookie: '__Host-poppy_session=untrusted' }), - mockOptions({ apiClient: createBackendApiClient({ publishableKey: PK_TEST }), __experimental_poppy: true }), + mockOptions({ apiClient: createBackendApiClient({}), __experimental_poppy: true }), ); expect(state.isAuthenticated).toBe(true); expect(state.poppy).toBeFalsy(); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 4721023739f..706471e912f 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -941,7 +941,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( throw new Error('A Clerk API client is required to verify Poppy account authority.'); } try { - return await options.apiClient.__experimental_poppy.resolveSession(credential); + return await options.apiClient.__experimental_poppy.resolveSession(credential, authenticateContext.frontendApi); } catch (error) { if (isClerkAPIResponseError(error) && (error.status === 401 || error.status === 404)) { return null; From a848e4caf2482c73bb99248abf1715cbae5a1e68 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 10 Nov 2026 15:25:12 -0500 Subject: [PATCH 4/5] docs(changeset): consolidate Poppy release notes --- .changeset/poppy-browser-session-v04.md | 8 +++----- .changeset/poppy-server-handshake.md | 6 ------ 2 files changed, 3 insertions(+), 11 deletions(-) delete mode 100644 .changeset/poppy-server-handshake.md diff --git a/.changeset/poppy-browser-session-v04.md b/.changeset/poppy-browser-session-v04.md index 647eef4ad85..a40e96773bf 100644 --- a/.changeset/poppy-browser-session-v04.md +++ b/.changeset/poppy-browser-session-v04.md @@ -3,10 +3,8 @@ "@clerk/nextjs": minor --- -Add Poppy 0.4 website browser Sessions. Enable `__experimental_poppy: true` on `clerkMiddleware()` 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. +Add experimental support for Poppy 0.4 signed-in browser sessions with full account access. Enable the Clerk-defined `__experimental_poppy: true` option on `clerkMiddleware()` to publish Poppy and OAuth protected resource discovery on the application domain. Clerk hosts the browser session endpoint and uses its existing handshake to establish application cookies. -The Backend SDK checks current OAuth authority for Poppy-derived Clerk Session JWTs. Sign-out or revocation ends account access and removes the agent from the auth object. +The Backend SDK checks current OAuth authority for agent-attributed Clerk session JWTs. Sign-out or revocation ends account access. Read authenticated agent attribution through `auth().agent`, including the Clerk-defined experimental fields `__experimental_poppyClientId`, `__experimental_poppyUserId`, and `__experimental_poppySessionId`. These fields describe Poppy protocol identity and may change while Poppy is draft. Signed-out visitors have no authenticated agent identity. -The live Session resolver and metadata helper 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. +The session resolver and metadata helper are available through the Clerk-defined `clerkClient.__experimental_poppy` API. diff --git a/.changeset/poppy-server-handshake.md b/.changeset/poppy-server-handshake.md deleted file mode 100644 index dc12512aeec..00000000000 --- a/.changeset/poppy-server-handshake.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@clerk/backend": patch -"@clerk/nextjs": patch ---- - -Publish discovery with `__experimental_poppy: true`. Clerk FAPI hosts the browser join, redeems its ticket server-side, and uses the existing Clerk handshake to set application cookies. Remove the app-domain join proxy and customer callback page. From 8dbea1716e9aec96a3f2a480b4bb9e18d176ad32 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 10 Nov 2026 15:35:35 -0500 Subject: [PATCH 5/5] fix(backend): use native Clerk session authentication for agents --- .changeset/poppy-browser-session-v04.md | 4 +- ...{PoppyApi.test.ts => ArtifactsApi.test.ts} | 26 +-- .../backend/src/api/endpoints/ArtifactsApi.ts | 15 ++ .../backend/src/api/endpoints/PoppyApi.ts | 45 ------ packages/backend/src/api/factory.ts | 4 +- packages/backend/src/index.ts | 2 - .../src/jwt/__tests__/assertions.test.ts | 12 +- .../src/jwt/__tests__/verifyJwt.test.ts | 34 ---- packages/backend/src/jwt/algorithms.ts | 28 ++-- packages/backend/src/jwt/cryptoKeys.ts | 2 +- .../src/tokens/__tests__/authObjects.test.ts | 91 ++--------- .../src/tokens/__tests__/request.test.ts | 152 +++++++++++------- packages/backend/src/tokens/authObjects.ts | 22 +-- packages/backend/src/tokens/authStatus.ts | 9 +- .../backend/src/tokens/authenticateContext.ts | 2 - packages/backend/src/tokens/handshake.ts | 2 +- packages/backend/src/tokens/request.ts | 74 ++------- packages/backend/src/tokens/verify.ts | 4 +- .../__snapshots__/exports.test.ts.snap | 1 - .../server/__tests__/clerkMiddleware.test.ts | 94 ++++------- packages/nextjs/src/server/clerkMiddleware.ts | 33 +--- .../src/server/data/getAuthDataFromRequest.ts | 1 - packages/nextjs/src/server/index.ts | 1 - packages/nextjs/src/server/utils.ts | 16 +- 24 files changed, 222 insertions(+), 452 deletions(-) rename packages/backend/src/api/__tests__/{PoppyApi.test.ts => ArtifactsApi.test.ts} (59%) create mode 100644 packages/backend/src/api/endpoints/ArtifactsApi.ts delete mode 100644 packages/backend/src/api/endpoints/PoppyApi.ts diff --git a/.changeset/poppy-browser-session-v04.md b/.changeset/poppy-browser-session-v04.md index a40e96773bf..0841c223a63 100644 --- a/.changeset/poppy-browser-session-v04.md +++ b/.changeset/poppy-browser-session-v04.md @@ -5,6 +5,6 @@ Add experimental support for Poppy 0.4 signed-in browser sessions with full account access. Enable the Clerk-defined `__experimental_poppy: true` option on `clerkMiddleware()` to publish Poppy and OAuth protected resource discovery on the application domain. Clerk hosts the browser session endpoint and uses its existing handshake to establish application cookies. -The Backend SDK checks current OAuth authority for agent-attributed Clerk session JWTs. Sign-out or revocation ends account access. Read authenticated agent attribution through `auth().agent`, including the Clerk-defined experimental fields `__experimental_poppyClientId`, `__experimental_poppyUserId`, and `__experimental_poppySessionId`. These fields describe Poppy protocol identity and may change while Poppy is draft. Signed-out visitors have no authenticated agent identity. +Agent-attributed sessions use normal Clerk session JWT verification, renewal, and revocation behavior. Already-issued JWTs remain usable until expiry. Read agent attribution through the common Clerk-defined `auth().agent` shape: `type`, `sub`, and `task_id` when present. Signed-out visitors have no authenticated agent identity. -The session resolver and metadata helper are available through the Clerk-defined `clerkClient.__experimental_poppy` API. +Fetch the Poppy JSON artifact with the Clerk-defined `clerkClient.artifacts.__experimental_getPoppyJson()` method. diff --git a/packages/backend/src/api/__tests__/PoppyApi.test.ts b/packages/backend/src/api/__tests__/ArtifactsApi.test.ts similarity index 59% rename from packages/backend/src/api/__tests__/PoppyApi.test.ts rename to packages/backend/src/api/__tests__/ArtifactsApi.test.ts index 0ba47adbd38..bc8af5ef429 100644 --- a/packages/backend/src/api/__tests__/PoppyApi.test.ts +++ b/packages/backend/src/api/__tests__/ArtifactsApi.test.ts @@ -4,9 +4,8 @@ import { describe, expect, it } from 'vitest'; import { server } from '../../mock-server'; import { createBackendApiClient } from '../factory'; -describe('PoppyAPI', () => { +describe('ArtifactsAPI', () => { const frontendApi = 'https://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 = { @@ -22,11 +21,11 @@ describe('PoppyAPI', () => { return HttpResponse.json({ object: 'artifact', data: metadata }); }), ); - const { __experimental_poppy: poppy } = createBackendApiClient({ + const { artifacts } = createBackendApiClient({ apiUrl: 'https://api.clerk.test', secretKey: 'sk_live_example', }); - await expect(poppy.getMetadata()).resolves.toEqual(metadata); + await expect(artifacts.__experimental_getPoppyJson()).resolves.toEqual(metadata); }); it.each([401, 404, 503])('propagates Backend API artifact status %s', async status => { @@ -42,25 +41,10 @@ describe('PoppyAPI', () => { ), ), ); - const { __experimental_poppy: poppy } = createBackendApiClient({ + const { artifacts } = createBackendApiClient({ apiUrl: 'https://api.clerk.test', secretKey: 'sk_live_example', }); - await expect(poppy.getMetadata()).rejects.toMatchObject({ status }); + await expect(artifacts.__experimental_getPoppyJson()).rejects.toMatchObject({ status }); }); - - it.each([{ 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({}); - await expect(poppy.resolveSession(credential, frontendApi)).resolves.toEqual(identity); - }, - ); }); diff --git a/packages/backend/src/api/endpoints/ArtifactsApi.ts b/packages/backend/src/api/endpoints/ArtifactsApi.ts new file mode 100644 index 00000000000..72c20906e63 --- /dev/null +++ b/packages/backend/src/api/endpoints/ArtifactsApi.ts @@ -0,0 +1,15 @@ +import type { RequestFunction } from '../request'; + +export type PoppyMetadata = unknown; + +export class ArtifactsAPI { + constructor(private readonly request: RequestFunction) {} + + public async __experimental_getPoppyJson() { + const artifact = await this.request<{ object: 'artifact'; data: PoppyMetadata }>({ + method: 'GET', + path: '/artifacts/well-known-poppy', + }); + return artifact.data; + } +} diff --git a/packages/backend/src/api/endpoints/PoppyApi.ts b/packages/backend/src/api/endpoints/PoppyApi.ts deleted file mode 100644 index 9ca36bb837e..00000000000 --- a/packages/backend/src/api/endpoints/PoppyApi.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { RequestFunction } from '../request'; -import { buildRequest } from '../request'; - -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 PoppyCredential = { type: 'session'; token: string }; - -export class PoppyAPI { - constructor( - private readonly request: RequestFunction, - private readonly userAgent?: string, - ) {} - - public async getMetadata() { - const artifact = await this.request<{ object: 'artifact'; data: PoppyMetadata }>({ - method: 'GET', - path: '/artifacts/well-known-poppy', - }); - return artifact.data; - } - - public resolveSession(credential: PoppyCredential, frontendApi: string) { - const request = buildRequest({ - apiUrl: frontendApi.startsWith('http') ? frontendApi : `https://${frontendApi}`, - requireSecretKey: false, - userAgent: this.userAgent, - }); - return request({ - method: 'POST', - path: '/poppy/browser_sessions/resolve', - bodyParams: { credential }, - }); - } -} diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 3351a27f0d9..c5ac0bd2b36 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -35,8 +35,8 @@ import { WaitlistEntryAPI, WebhookAPI, } from './endpoints'; +import { ArtifactsAPI } from './endpoints/ArtifactsApi'; import { BillingAPI } from './endpoints/BillingApi'; -import { PoppyAPI } from './endpoints/PoppyApi'; import { buildRequest } from './request'; export type CreateBackendApiOptions = Parameters[0] & { @@ -64,6 +64,7 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { skipApiVersionInUrl: true, }), ), + artifacts: new ArtifactsAPI(request), betaFeatures: new BetaFeaturesAPI(request), blocklistIdentifiers: new BlocklistIdentifierAPI(request), /** @@ -108,7 +109,6 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { organizations: new OrganizationAPI(request), organizationPermissions: new OrganizationPermissionAPI(request), organizationRoles: new OrganizationRoleAPI(request), - __experimental_poppy: new PoppyAPI(request, options.userAgent), 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 b285b092640..65b4c1127ff 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -203,5 +203,3 @@ export type { */ export type { AuthObject, InvalidTokenAuthObject } from './tokens/authObjects'; export type { SessionAuthObject, MachineAuthObject } from './tokens/types'; - -export type { PoppyBrowserSession } 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 a838afb9244..42ed0944e96 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,EdDSA.`); + expect(() => assertHeaderAlgorithm('')).toThrow(`Invalid JWT algorithm "". Supported: RS256,RS384,RS512.`); }); it('throws error if algorithm is not supported', () => { expect(() => assertHeaderAlgorithm('ES256')).toThrow( - `Invalid JWT algorithm "ES256". Supported: RS256,RS384,RS512,EdDSA.`, + `Invalid JWT algorithm "ES256". Supported: RS256,RS384,RS512.`, ); expect(() => assertHeaderAlgorithm('ES384')).toThrow( - `Invalid JWT algorithm "ES384". Supported: RS256,RS384,RS512,EdDSA.`, + `Invalid JWT algorithm "ES384". Supported: RS256,RS384,RS512.`, ); expect(() => assertHeaderAlgorithm('ES512')).toThrow( - `Invalid JWT algorithm "ES512". Supported: RS256,RS384,RS512,EdDSA.`, + `Invalid JWT algorithm "ES512". Supported: RS256,RS384,RS512.`, ); expect(() => assertHeaderAlgorithm('PS512')).toThrow( - `Invalid JWT algorithm "PS512". Supported: RS256,RS384,RS512,EdDSA.`, + `Invalid JWT algorithm "PS512". Supported: RS256,RS384,RS512.`, ); expect(() => assertHeaderAlgorithm('Aloha')).toThrow( - `Invalid JWT algorithm "Aloha". Supported: RS256,RS384,RS512,EdDSA.`, + `Invalid JWT algorithm "Aloha". Supported: RS256,RS384,RS512.`, ); }); }); diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts index 2f37ccd7d8c..a6df580696f 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -16,8 +16,6 @@ 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'; @@ -27,38 +25,6 @@ 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 776d29dcadd..2fdfa0edc79 100644 --- a/packages/backend/src/jwt/algorithms.ts +++ b/packages/backend/src/jwt/algorithms.ts @@ -1,18 +1,28 @@ +const algToHash: Record = { + RS256: 'SHA-256', + RS384: 'SHA-384', + RS512: 'SHA-512', +}; const RSA_ALGORITHM_NAME = 'RSASSA-PKCS1-v1_5'; -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' }, +const jwksAlgToCryptoAlg: Record = { + RS256: RSA_ALGORITHM_NAME, + RS384: RSA_ALGORITHM_NAME, + RS512: RSA_ALGORITHM_NAME, }; -export const algs = Object.keys(jwksAlgToCryptoAlg); +export const algs = Object.keys(algToHash); + +export function getCryptoAlgorithm(algorithmName: string): RsaHashedImportParams { + const hash = algToHash[algorithmName]; + const name = jwksAlgToCryptoAlg[algorithmName]; -export function getCryptoAlgorithm(algorithmName: string): RsaHashedImportParams | Algorithm { - if (!algs.includes(algorithmName)) { + if (!hash || !name) { throw new Error(`Unsupported algorithm ${algorithmName}, expected one of ${algs.join(',')}.`); } - return jwksAlgToCryptoAlg[algorithmName]; + return { + hash: { name: algToHash[algorithmName] }, + name: jwksAlgToCryptoAlg[algorithmName], + }; } diff --git a/packages/backend/src/jwt/cryptoKeys.ts b/packages/backend/src/jwt/cryptoKeys.ts index 4955db48386..bdc1f42d6f3 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, + algorithm: RsaHashedImportParams, keyUsage: 'verify' | 'sign', ): Promise { if (typeof key === 'object') { diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index 7a96b9e79c9..cc62c8212c2 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -1,7 +1,6 @@ -import type { JwtPayload } from '@clerk/shared/types'; +import type { AgentActClaim, JwtPayload } from '@clerk/shared/types'; 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'; @@ -35,52 +34,30 @@ 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: 'ordinary Session has no agent', claims: {}, 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: 'Poppy Session uses the existing Session agent shape', + claims: { agt: { id: 'https://agent.example', poppy: { session_id: 'protocol-session' } } }, + expected: { type: 'agent', sub: 'https://agent.example' }, }, { 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', { + ])('$name', ({ claims, expected }) => { + const auth = signedInAuthObject({}, 'token', { ...mockJwtPayload, __raw: 'token', ...claims, @@ -89,78 +66,34 @@ describe('signedInAuthObject', () => { }); expect(auth.agent).toEqual(expected); - expect(Object.keys(auth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]); + expectTypeOf(auth.agent).toEqualTypeOf(); 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 }); + it('omits agent attribution from signed-out auth', async () => { + const auth = signedOutAuthObject(); 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 } }, + payload: { sub: 'user_123', sid: 'sess_123', sts: 'pending', agt: { id: 'https://agent.example' } }, }), ), - { 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'); diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 824517996cf..a5c5a96ff99 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1,15 +1,16 @@ 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, mockInvalidSignatureJwt, mockJwks, mockJwt, + mockJwtHeader, mockJwtPayload, mockM2MJwtPayload, + pemEncodedPublicKey, signingJwks, } from '../../fixtures'; import { @@ -20,6 +21,7 @@ import { } from '../../fixtures/machine'; import { signJwt } from '../../jwt/signJwt'; import { server } from '../../mock-server'; +import { getCookieSuffix } from '../../util/shared'; import type { AuthReason } from '../authStatus'; import { AuthErrorReason, AuthStatus } from '../authStatus'; import { JWT_CATEGORY_JWT_TEMPLATE, JWT_CATEGORY_M2M_TOKEN, JWT_CATEGORY_SESSION_TOKEN } from '../jwtCategories'; @@ -510,76 +512,118 @@ describe('tokens.authenticateRequest(options)', () => { vi.clearAllMocks(); }); - describe('Poppy account precedence', () => { + describe('agent Session JWT authentication', () => { 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([true, false])('checks a Poppy JWT without its browser cookie; live grant=%s', async active => { + it.each([ + { name: 'valid bearer token', cookie: false, expired: false }, + { name: 'valid session cookie', cookie: true, expired: false }, + { name: 'expired bearer token', cookie: false, expired: true }, + ])('uses normal JWT verification for $name without a backend call', async ({ cookie, expired }) => { 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' }, + ...mockJwtPayload, + ...(expired ? { exp: mockJwtPayload.iat - 10 } : {}), + agt: { id: identity.client_id, poppy: identity }, }, + signingJwks, + { algorithm: 'RS256', header: { ...mockJwtHeader } }, ); - 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 fetch = vi.spyOn(globalThis, 'fetch'); + const request = cookie + ? mockRequestWithCookies({}, { __session: jwt, __client_uat: mockJwtPayload.iat }) + : mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }); const state = await authenticateRequest( - mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }), - mockOptions({ apiClient: createBackendApiClient({}) }), + request, + mockOptions({ jwtKey: pemEncodedPublicKey, publishableKey: PK_LIVE }), ); - expect(state.isAuthenticated).toBe(active); - const auth = state.toAuth(); - if (active) { - expect(auth).toMatchObject({ + expect(fetch).not.toHaveBeenCalled(); + expect(state.isAuthenticated).toBe(!expired); + if (expired) { + expect(state.status).toBe(AuthStatus.Handshake); + expect(state.toAuth()).toBeNull(); + } else { + expect(state.toAuth()).toMatchObject({ + userId: mockJwtPayload.sub, + sessionId: mockJwtPayload.sid, 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 for a native agent Session', async () => { - const { data: jwt } = await signJwt( - { ...mockJwtPayload, agt: { id: identity.client_id, poppy: { session_id: identity.session_id } } }, - 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', () => - HttpResponse.json({ errors: [] }, { status: 503 }), - ), - ); - await expect( - authenticateRequest( - mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }), - mockOptions({ apiClient: createBackendApiClient({}) }), - ), - ).rejects.toMatchObject({ status: 503 }); - }); + fetch.mockRestore(); + }); + + it.each([ + { production: true, discovery: true }, + { production: true, discovery: false }, + { production: false, discovery: true }, + { production: false, discovery: false }, + ])( + 'accepts a signed agent handshake with production=$production and discovery=$discovery', + async ({ production, discovery }) => { + const publishableKey = production ? PK_LIVE : PK_TEST; + const suffix = await getCookieSuffix(publishableKey); + const { data: sessionToken } = await signJwt( + { ...mockJwtPayload, agt: { id: identity.client_id, reg: identity.client_id, poppy: identity } }, + signingJwks, + { algorithm: 'RS256', header: { ...mockJwtHeader, cat: JWT_CATEGORY_SESSION_TOKEN } }, + ); + const cookies = { + __client_uat: String(mockJwtPayload.iat), + [`__client_uat_${suffix}`]: String(mockJwtPayload.iat), + [`__session_${suffix}`]: sessionToken!, + ...(!production ? { [`__clerk_db_jwt_${suffix}`]: 'dev-browser' } : {}), + }; + const directives = Object.entries(cookies).map( + ([name, value]) => `${name}=${value}; Path=/; SameSite=Lax; Secure`, + ); + if (production) { + directives.unshift('__clerk_handshake=; Path=/; Max-Age=0'); + } + const { data: handshake } = await signJwt({ handshake: directives }, signingJwks, { + algorithm: 'RS256', + header: { ...mockJwtHeader, cat: JWT_CATEGORY_SESSION_TOKEN }, + }); + const url = new URL('https://example.com/'); + if (!production) { + url.searchParams.set('__clerk_handshake', handshake!); + url.searchParams.set('__clerk_db_jwt', 'dev-browser'); + } + const fetch = vi.spyOn(globalThis, 'fetch'); + const options = mockOptions({ jwtKey: pemEncodedPublicKey, publishableKey, __experimental_poppy: discovery }); + const state = await authenticateRequest( + mockRequestWithCookies({}, production ? { __clerk_handshake: handshake! } : {}, url.toString()), + options, + ); + const expectedAuth = { + userId: mockJwtPayload.sub, + sessionId: mockJwtPayload.sid, + agent: { type: 'agent', sub: identity.client_id }, + }; + expect(state.isAuthenticated).toBe(true); + expect(state.toAuth()).toMatchObject(expectedAuth); + expect(state.headers.getSetCookie()).toEqual(directives); + expect(state.headers.get('location')).toBe(production ? null : 'https://example.com/'); + const nextRequest = await authenticateRequest( + mockRequestWithCookies({}, cookies, 'https://example.com/'), + options, + ); + expect(nextRequest.isAuthenticated).toBe(true); + expect(nextRequest.toAuth()).toMatchObject(expectedAuth); + expect(fetch).not.toHaveBeenCalled(); + fetch.mockRestore(); + }, + ); - it('does not interpret an application cookie as a Poppy credential', async () => { - server.use(http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks))); + it('does not interpret an application cookie as authenticated agent identity', async () => { const state = await authenticateRequest( - mockRequestWithHeaderAuth({ cookie: '__Host-poppy_session=untrusted' }), - mockOptions({ apiClient: createBackendApiClient({}), __experimental_poppy: true }), + mockRequestWithCookies({}, { '__Host-poppy_session': 'untrusted' }), + mockOptions({ jwtKey: pemEncodedPublicKey, publishableKey: PK_LIVE, __experimental_poppy: true }), ); - expect(state.isAuthenticated).toBe(true); - expect(state.poppy).toBeFalsy(); + expect(state.isAuthenticated).toBe(false); + expect(state.toAuth()).toMatchObject({ userId: null, sessionId: null }); expect(state.toAuth()).not.toHaveProperty('agent'); }); }); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index c8a31d95a76..4e0634f2bd2 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -14,7 +14,6 @@ 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'; @@ -33,28 +32,13 @@ 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 getAuthAgent = (claims: JwtPayload): AgentActClaim | 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; @@ -71,7 +55,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & { * @internal */ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { - agent?: AuthAgent; + agent?: AgentActClaim; /** * The allowed token type. */ @@ -229,7 +213,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 agent = getAuthAgent(sessionClaims); const getToken = createGetToken({ sessionId, sessionToken, diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 6952f22aad7..5282aa4b3a8 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -1,6 +1,5 @@ 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'; @@ -41,7 +40,6 @@ type ToAuth = T exten : () => UnauthenticatedMachineObject>; export type AuthenticatedState = { - poppy?: PoppyBrowserSession | null; status: typeof AuthStatus.SignedIn; reason: null; message: null; @@ -65,7 +63,6 @@ export type AuthenticatedState = { }; export type UnauthenticatedState = { - poppy?: PoppyBrowserSession | null; status: typeof AuthStatus.SignedOut; reason: AuthReason; message: string; @@ -121,7 +118,6 @@ 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; @@ -154,10 +150,7 @@ export function signedIn(params: SignedInParams & { tokenTy const authObject = signedInAuthObject(authenticateContext, token, sessionClaims); if (treatPendingAsSignedOut && authObject.sessionStatus === 'pending') { - return signedOutAuthObject( - authenticateContext.poppySession ? { poppySession: authenticateContext.poppySession } : undefined, - authObject.sessionStatus, - ); + return signedOutAuthObject(undefined, authObject.sessionStatus); } return authObject; diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index f720435944d..427b49ae7b3 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -3,7 +3,6 @@ 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'; @@ -14,7 +13,6 @@ 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 5ceb0e234b1..5dc9b3a2c2f 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -80,7 +80,7 @@ export async function verifyHandshakeToken( let key; if (jwtKey) { - key = data.header.alg === 'EdDSA' ? jwtKey : loadClerkJwkFromPem({ kid, pem: jwtKey }); + key = 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 706471e912f..bd0a74895f8 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,8 +1,6 @@ -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 { constants } from '../constants'; import type { TokenCarrier } from '../errors'; import { MachineTokenVerificationError, TokenVerificationError, TokenVerificationErrorReason } from '../errors'; @@ -152,8 +150,7 @@ export const authenticateRequest: AuthenticateRequest = (async ( request: Request, options: AuthenticateRequestOptions, ): Promise | UnauthenticatedState> => { - const clerkRequest = createClerkRequest(request); - const authenticateContext = await createAuthenticateContext(clerkRequest, options); + const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options); // Default tokenType is session_token for backwards compatibility. const acceptsToken = options.acceptsToken ?? TokenType.SessionToken; @@ -899,16 +896,18 @@ export const authenticateRequest: AuthenticateRequest = (async ( } } - let state: RequestState | UnauthenticatedState; if (authenticateContext.tokenInHeader) { if (acceptsToken === 'any' || Array.isArray(acceptsToken)) { - state = await authenticateAnyRequestWithTokenInHeader(); - } else if (acceptsToken === TokenType.SessionToken) { - state = await authenticateRequestWithTokenInHeader(); - } else { - state = await authenticateMachineRequestWithTokenInHeader(); + return authenticateAnyRequestWithTokenInHeader(); + } + if (acceptsToken === TokenType.SessionToken) { + return authenticateRequestWithTokenInHeader(); } - } else if ( + return authenticateMachineRequestWithTokenInHeader(); + } + + // Machine requests cannot have the token in the cookie, it must be in header. + if ( acceptsToken === TokenType.OAuthToken || acceptsToken === TokenType.ApiKey || acceptsToken === TokenType.M2MToken @@ -918,59 +917,14 @@ 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, authenticateContext.frontendApi); - } catch (error) { - if (isClerkAPIResponseError(error) && (error.status === 401 || error.status === 404)) { - return null; - } - throw error; - } - }; - - if (!state.isAuthenticated) { - return state; - } - const auth = state.toAuth(); - if (auth.tokenType !== TokenType.SessionToken) { - return state; - } - const agent: unknown = auth.sessionClaims.agt; - const isPoppySession = typeof agent === 'object' && agent !== null && 'poppy' in agent; - if (!isPoppySession) { - return state; - } - const current = await resolvePoppy({ type: 'session', token: state.token }); - if (!current?.account || current.account.session_id !== auth.sessionId || current.account.user_id !== auth.userId) { - return rejectPoppyAccount(); - } - authenticateContext.poppySession = current; - state.poppy = current; - return state; + return authenticateRequestWithTokenInCookie(); }) 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/verify.ts b/packages/backend/src/tokens/verify.ts index 6be42d89d12..1ed485cd71d 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -136,10 +136,10 @@ export async function verifyToken( } try { - let key: JsonWebKey | string; + let key: JsonWebKey; if (options.jwtKey) { - key = decodedResult.header.alg === 'EdDSA' ? options.jwtKey : loadClerkJwkFromPem({ kid, pem: options.jwtKey }); + key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); } else if (options.secretKey) { key = await loadClerkJWKFromRemote({ ...options, kid }); } else { 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 3c7af9e6af4..fcbeab2a164 100644 --- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap @@ -2,7 +2,6 @@ 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 ea4d2e7d400..0d581f1b1ed 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1541,36 +1541,8 @@ describe('contentSecurityPolicy option', () => { describe('Poppy middleware', () => { const endpoint = 'https://clerk.company.example/v1/poppy/browser_sessions'; - 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: true }); - 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: 'discovery is enabled', enabled: true, status: 403, code: 'insufficient_scope' }, { 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' }, @@ -1601,9 +1573,9 @@ describe('Poppy middleware', () => { 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(metadata); + const __experimental_getPoppyJson = vi.fn().mockResolvedValue(metadata); vi.mocked(clerkClient).mockResolvedValue( - Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), ); const middleware = clerkMiddleware({ __experimental_poppy: true }); const response = await middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent); @@ -1620,7 +1592,7 @@ describe('Poppy middleware', () => { }, ); expect(response.headers.get('cache-control')).toBe('no-store'); - expect(getMetadata).toHaveBeenCalledExactlyOnceWith(); + expect(__experimental_getPoppyJson).toHaveBeenCalledExactlyOnceWith(); expect(authenticateRequestMock).not.toHaveBeenCalled(); }, ); @@ -1631,9 +1603,9 @@ describe('Poppy middleware', () => { { 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(); + const __experimental_getPoppyJson = vi.fn(); vi.mocked(clerkClient).mockResolvedValue( - Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), ); const middleware = clerkMiddleware({ __experimental_poppy: true }); const response = await middleware( @@ -1643,32 +1615,32 @@ describe('Poppy middleware', () => { assert(response instanceof Response); expect(response.status).toBe(405); expect(response.headers.get('allow')).toBe('GET'); - expect(getMetadata).not.toHaveBeenCalled(); + expect(__experimental_getPoppyJson).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(); + const __experimental_getPoppyJson = vi.fn(); vi.mocked(clerkClient).mockResolvedValue( - Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), ); 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(__experimental_getPoppyJson).not.toHaveBeenCalled(); expect(handler).toHaveBeenCalledOnce(); expect(authenticateRequestMock).toHaveBeenCalledOnce(); }, ); it('serves discovery for the configured Company host behind a reverse proxy', async () => { - const getMetadata = vi.fn().mockResolvedValue(metadata); + const __experimental_getPoppyJson = vi.fn().mockResolvedValue(metadata); vi.mocked(clerkClient).mockResolvedValue( - Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), ); const middleware = clerkMiddleware({ __experimental_poppy: true }); const response = await middleware( @@ -1680,17 +1652,17 @@ describe('Poppy middleware', () => { ); assert(response instanceof Response); expect(await response.json()).toEqual(metadata); - expect(getMetadata).toHaveBeenCalledExactlyOnceWith(); + expect(__experimental_getPoppyJson).toHaveBeenCalledExactlyOnceWith(); expect(authenticateRequestMock).not.toHaveBeenCalled(); }); it('rejects discovery on a different Company origin', async () => { - const getMetadata = vi.fn().mockResolvedValue({ + const __experimental_getPoppyJson = vi.fn().mockResolvedValue({ ...metadata, organization: { ...metadata.organization, domain: 'other.example' }, }); vi.mocked(clerkClient).mockResolvedValue( - Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), ); const middleware = clerkMiddleware({ __experimental_poppy: true }); await expect( @@ -1702,9 +1674,9 @@ describe('Poppy middleware', () => { 'propagates an artifact failure for %s without fabricated discovery', async path => { const failure = new Error('Backend artifact unavailable'); - const getMetadata = vi.fn().mockRejectedValue(failure); + const __experimental_getPoppyJson = vi.fn().mockRejectedValue(failure); vi.mocked(clerkClient).mockResolvedValue( - Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), ); const middleware = clerkMiddleware({ __experimental_poppy: true }); await expect( @@ -1714,18 +1686,17 @@ describe('Poppy middleware', () => { }, ); - 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'] } } - : {}), - }; + it.each([true, false])('exposes common agent attribution only when signed in=%s', async signedIn => { + const agentId = 'https://agent.example'; 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 }), + JSON.stringify({ + sub: 'user_123', + sid: 'sess_123', + agt: { id: agentId, poppy: { session_id: 'protocol-session' } }, + iat: 0, + exp: 9999999999, + }), 'signature', ] .map(part => Buffer.from(part).toString('base64url')) @@ -1734,7 +1705,6 @@ describe('Poppy middleware', () => { status: signedIn ? AuthStatus.SignedIn : AuthStatus.SignedOut, token: signedIn ? token : null, tokenType: TokenType.SessionToken, - poppy: identity, toAuth: () => ({ tokenType: TokenType.SessionToken, isAuthenticated: signedIn, @@ -1747,10 +1717,6 @@ describe('Poppy middleware', () => { const middleware = clerkMiddleware({ __experimental_poppy: true }); 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-')) { @@ -1761,16 +1727,12 @@ describe('Poppy middleware', () => { 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, + sub: agentId, }); - expectTypeOf(appAuth.agent?.__experimental_poppySessionId).toEqualTypeOf(); + expectTypeOf(appAuth.agent?.sub).toEqualTypeOf(); } else { expect(appAuth).not.toHaveProperty('agent'); } diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index ea9c1c9b287..06b5416644f 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -20,7 +20,6 @@ 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'; @@ -216,7 +215,10 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl if (request.method !== 'GET') { return new Response(null, { status: 405, headers: { Allow: 'GET' } }); } - const metadata = await resolvedClerkClient.__experimental_poppy.getMetadata(); + const metadata = (await resolvedClerkClient.artifacts.__experimental_getPoppyJson()) as { + organization: { domain: string }; + auth: { issuer: string; scopes: Record }; + }; if (poppyRequestUrl.origin !== `https://${metadata.organization.domain}`) { throw new Error('Poppy discovery must use the configured Company origin.'); } @@ -253,29 +255,10 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl })); } - 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; - } + const requestState = await resolvedClerkClient.authenticateRequest( + clerkRequest, + createAuthenticateRequestOptions(clerkRequest, options), + ); return runHandlerWithRequestState({ clerkRequest, diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index 0656d610b29..348e7f277e2 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -60,7 +60,6 @@ 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 48c39320849..477ad2eac59 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -51,7 +51,6 @@ export type { ClerkMiddlewareAuth, ClerkMiddlewareSessionAuthObject, ClerkMiddle */ export type { OrganizationMembershipRole, - PoppyBrowserSession, // Resources AllowlistIdentifier, Client, diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index 62f297331d0..e6504478823 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -1,4 +1,4 @@ -import type { AuthObject, PoppyBrowserSession } from '@clerk/backend'; +import type { AuthObject } 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, requestState.poppy); + const clerkRequestData = encryptClerkRequestData(requestData, keylessMode, machineAuthObject); setRequestHeadersOnNextResponse(res, req, { [constants.Headers.AuthStatus]: status, @@ -210,7 +210,6 @@ export function encryptClerkRequestData( requestData: Partial, keylessModeKeys: Pick, machineAuthObject: AuthObject | null, - poppySession?: PoppyBrowserSession | null, ) { const isEmpty = (obj: Record | undefined) => { if (!obj) { @@ -219,7 +218,7 @@ export function encryptClerkRequestData( return !Object.values(obj).some(v => v !== undefined); }; - if (isEmpty(requestData) && isEmpty(keylessModeKeys) && !machineAuthObject && !poppySession) { + if (isEmpty(requestData) && isEmpty(keylessModeKeys) && !machineAuthObject) { return; } @@ -232,12 +231,7 @@ export function encryptClerkRequestData( : ENCRYPTION_KEY || SECRET_KEY || KEYLESS_ENCRYPTION_KEY; return AES.encrypt( - JSON.stringify({ - ...keylessModeKeys, - ...requestData, - machineAuthObject: machineAuthObject ?? undefined, - poppySession, - }), + JSON.stringify({ ...keylessModeKeys, ...requestData, machineAuthObject: machineAuthObject ?? undefined }), maybeKeylessEncryptionKey, ).toString(); } @@ -248,7 +242,7 @@ export function encryptClerkRequestData( */ export function decryptClerkRequestData( encryptedRequestData?: string | undefined | null, -): Partial & { machineAuthObject?: AuthObject; poppySession?: PoppyBrowserSession | null } { +): Partial & { machineAuthObject?: AuthObject } { if (!encryptedRequestData) { return {}; }