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 77db0fe2554..b049c21b528 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -1,6 +1,8 @@ +import { getCookieSuffix } from '@clerk/shared/keys'; import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; +import { createBackendApiClient } from '../../api/factory'; import { MachineTokenVerificationErrorCode, TokenVerificationErrorReason } from '../../errors'; import { mockExpiredJwt, @@ -509,6 +511,360 @@ describe('tokens.authenticateRequest(options)', () => { vi.clearAllMocks(); }); + describe('Poppy account precedence', () => { + const endpoint = 'https://example.com/poppy/browser-session'; + const browserToken = 'a'.repeat(40); + const identity = { session_id: 'protocol-session', client_id: 'https://agent.example', user_id: 'pseudonym' }; + const account = { user_id: mockJwtPayload.sub, session_id: mockJwtPayload.sid, scopes: ['poppy:browser-sign-in'] }; + + it.each([ + { name: 'guest join preserves an independent Clerk login', body: identity, authenticated: true }, + { + name: 'signed-in join accepts its matching Clerk Session', + body: { ...identity, account }, + authenticated: true, + }, + { + name: 'pending join rejects an earlier account', + body: { ...identity, account: { ...account, session_id: 'sess_other' } }, + authenticated: false, + }, + ])('$name', async ({ body, authenticated }) => { + let requests = 0; + server.use( + http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)), + http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', async ({ request }) => { + expect(await request.json()).toEqual({ credential: { type: 'browser', endpoint, token: browserToken } }); + requests++; + return HttpResponse.json(body); + }), + ); + const state = await authenticateRequest( + mockRequestWithHeaderAuth({ cookie: `__Host-poppy_session=${browserToken}` }, 'https://example.com/path'), + mockOptions({ + apiClient: createBackendApiClient({ publishableKey: PK_TEST }), + __experimental_poppy: { browserSessionEndpoint: endpoint }, + }), + ); + expect(state.isAuthenticated).toBe(authenticated); + const auth = state.toAuth(); + expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]); + expect(auth).not.toHaveProperty('agent'); + expect(state.poppy).toEqual(body); + expect(requests).toBe(1); + if (!authenticated) { + expect(state.reason).toBe(AuthErrorReason.PoppyAccountInvalid); + } + }); + + it.each([ + { + name: 'verified guest renders without development browser sync', + cookies: {}, + body: identity, + status: 200, + handshake: false, + }, + { + name: 'missing guest identity retains development browser sync', + cookies: {}, + body: null, + status: 404, + handshake: true, + }, + { + name: 'existing native Session retains development browser sync', + cookies: { __session: mockJwt }, + body: identity, + status: 200, + handshake: true, + }, + { + name: 'expired native Session retains development browser sync', + cookies: { __session: mockExpiredJwt }, + body: identity, + status: 200, + handshake: true, + }, + { + name: 'existing native client authority retains development browser sync', + cookies: { __client_uat: '12345' }, + body: identity, + status: 200, + handshake: true, + }, + { + name: 'native refresh credential retains development browser sync', + cookies: { __refresh: 'refresh-token' }, + body: identity, + status: 200, + handshake: true, + }, + { + name: 'pending account activation retains development browser sync', + cookies: {}, + body: { ...identity, account }, + status: 200, + handshake: true, + }, + ])('$name', async ({ cookies, body, status, handshake }) => { + const suffix = await getCookieSuffix(PK_TEST); + const nativeCookies = Object.fromEntries( + Object.entries(cookies).map(([name, value]) => [name === '__refresh' ? `__refresh_${suffix}` : name, value]), + ); + let lookups = 0; + server.use( + http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)), + http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () => { + lookups++; + return HttpResponse.json(body, { status }); + }), + ); + const state = await authenticateRequest( + mockRequestWithCookies( + {}, + { '__Host-poppy_session': browserToken, ...nativeCookies }, + 'https://example.com/path', + ), + mockOptions({ + apiClient: createBackendApiClient({ publishableKey: PK_TEST }), + __experimental_poppy: { browserSessionEndpoint: endpoint }, + }), + ); + expect(state.status).toBe(handshake ? 'handshake' : 'signed-out'); + expect(lookups).toBe(1); + if (!handshake) { + expect(state.headers.has('location')).toBe(false); + expect(state.poppy).toEqual(identity); + const auth = state.toAuth(); + expect(auth).not.toHaveProperty('agent'); + expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]); + } + }); + + it.each([ + { + name: 'valid stale Poppy Session', + poppy: true, + expired: false, + forged: false, + devBrowser: false, + rejected: true, + }, + { + name: 'valid stale Poppy Session with development browser', + poppy: true, + expired: false, + forged: false, + devBrowser: true, + rejected: true, + }, + { + name: 'expired stale Poppy Session', + poppy: true, + expired: true, + forged: false, + devBrowser: false, + rejected: true, + }, + { + name: 'expired stale Poppy Session with development browser', + poppy: true, + expired: true, + forged: false, + devBrowser: true, + rejected: true, + }, + { name: 'forged Poppy Session', poppy: true, expired: false, forged: true, devBrowser: false, rejected: false }, + { + name: 'expired forged Poppy Session', + poppy: true, + expired: true, + forged: true, + devBrowser: false, + rejected: false, + }, + { + name: 'independent human Session', + poppy: false, + expired: false, + forged: false, + devBrowser: false, + rejected: false, + }, + { + name: 'expired independent human Session', + poppy: false, + expired: true, + forged: false, + devBrowser: false, + rejected: false, + }, + ])( + '$name does not grant account access after guest rejoin', + async ({ poppy, expired, forged, devBrowser, rejected }) => { + const { data: token } = await signJwt( + { + ...mockJwtPayload, + ...(expired ? { exp: mockJwtPayload.iat - 60 } : {}), + ...(poppy ? { agt: { id: 'client', poppy: { session_id: 'protocol-session' } } } : {}), + }, + signingJwks, + { + algorithm: 'RS256', + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + }, + ); + if (!token) { + throw new Error('Failed to create the test JWT.'); + } + const parts = token.split('.'); + if (forged) { + parts[2] = (parts[2].startsWith('A') ? 'B' : 'A') + parts[2].slice(1); + } + let keyLoads = 0; + let lookups = 0; + server.use( + http.get('https://api.clerk.test/v1/jwks', () => { + keyLoads++; + return HttpResponse.json(mockJwks); + }), + http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () => { + lookups++; + return HttpResponse.json(identity); + }), + ); + const state = await authenticateRequest( + mockRequestWithCookies( + {}, + { + '__Host-poppy_session': browserToken, + __session: parts.join('.'), + __client_uat: '0', + ...(devBrowser ? { __clerk_db_jwt: 'development-browser' } : {}), + }, + 'https://example.com/path', + ), + mockOptions({ + apiClient: createBackendApiClient({ publishableKey: PK_TEST }), + __experimental_poppy: { browserSessionEndpoint: endpoint }, + }), + ); + expect(state.isAuthenticated).toBe(false); + expect(lookups).toBe(1); + expect(keyLoads).toBe(1); + expect(state.poppy).toEqual(identity); + if (rejected) { + expect(state.status).toBe('signed-out'); + expect(state.reason).toBe(AuthErrorReason.PoppyAccountInvalid); + expect(state.headers.has('location')).toBe(false); + const auth = state.toAuth(); + expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]); + expect(auth && 'userId' in auth ? auth.userId : undefined).toBeNull(); + expect(auth).not.toHaveProperty('agent'); + } else { + expect(state.status).toBe('handshake'); + expect(state.headers.has('location')).toBe(true); + } + }, + ); + + it.each(['authority store', 'JWKS'] as const)( + 'propagates a stale Session %s failure without treating it as revocation', + async failing => { + vi.useRealTimers(); + server.use( + http.get('https://api.clerk.test/v1/jwks', () => + HttpResponse.json(failing === 'JWKS' ? { errors: [] } : mockJwks, { + status: failing === 'JWKS' ? 503 : 200, + }), + ), + http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () => + HttpResponse.json(failing === 'authority store' ? { errors: [] } : identity, { + status: failing === 'authority store' ? 503 : 200, + }), + ), + ); + const result = authenticateRequest( + mockRequestWithCookies( + {}, + { + '__Host-poppy_session': browserToken, + __session: mockJwt, + __client_uat: '0', + }, + 'https://example.com/path', + ), + mockOptions({ + apiClient: createBackendApiClient({ publishableKey: PK_TEST }), + __experimental_poppy: { browserSessionEndpoint: endpoint }, + }), + ); + await expect(result).rejects.toMatchObject( + failing === 'authority store' + ? { status: 503 } + : { reason: TokenVerificationErrorReason.RemoteJWKFailedToLoad }, + ); + }, + ); + + it.each([true, false])('checks a Poppy JWT without its browser cookie; live grant=%s', async active => { + const { data: jwt } = await signJwt( + { ...mockJwtPayload, agt: { id: identity.client_id, poppy: { session_id: 'protocol-session' } } }, + signingJwks, + { + algorithm: 'RS256', + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' }, + }, + ); + server.use( + http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)), + http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', async ({ request }) => { + expect(await request.json()).toEqual({ credential: { type: 'session', token: jwt } }); + return HttpResponse.json(active ? { ...identity, account } : { errors: [] }, { status: active ? 200 : 401 }); + }), + ); + const state = await authenticateRequest( + mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }), + mockOptions({ apiClient: createBackendApiClient({ publishableKey: PK_TEST }) }), + ); + expect(state.isAuthenticated).toBe(active); + const auth = state.toAuth(); + if (active) { + expect(auth).toMatchObject({ + agent: { + type: 'agent', + sub: identity.client_id, + __experimental_poppyClientId: identity.client_id, + __experimental_poppyUserId: identity.user_id, + __experimental_poppySessionId: identity.session_id, + }, + }); + expect(Object.keys(auth!).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]); + } else { + expect(auth).not.toHaveProperty('agent'); + } + }); + + it('propagates an authority store outage', async () => { + server.use( + http.get('https://api.clerk.test/v1/jwks', () => HttpResponse.json(mockJwks)), + http.post('https://clerk.inspired.puma-74.lcl.dev/v1/poppy/browser_sessions/resolve', () => + HttpResponse.json({ errors: [] }, { status: 503 }), + ), + ); + await expect( + authenticateRequest( + mockRequestWithHeaderAuth({ cookie: `__Host-poppy_session=${browserToken}` }, 'https://example.com/path'), + mockOptions({ + apiClient: createBackendApiClient({ publishableKey: PK_TEST }), + __experimental_poppy: { browserSessionEndpoint: endpoint }, + }), + ), + ).rejects.toMatchObject({ status: 503 }); + }); + }); + // // HTTP Authorization exists // diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 44391b388da..c8a31d95a76 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,6 +1,7 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser'; import type { + AgentActClaim, CheckAuthorizationFromSessionClaims, Jwt, JwtPayload, @@ -13,6 +14,7 @@ import type { import type { APIKey, CreateBackendApiOptions, IdPOAuthAccessToken, M2MToken } from '../api'; import { createBackendApiClient } from '../api'; +import type { PoppyBrowserSession } from '../api/endpoints/PoppyApi'; import { isTokenTypeAccepted } from '../internal'; import type { AuthenticateContext } from './authenticateContext'; import { isMachineTokenType } from './machine'; @@ -31,6 +33,33 @@ type AuthObjectDebug = () => AuthObjectDebugData; type Claims = Record; +type AuthAgent = AgentActClaim & { + __experimental_poppyClientId?: string; + __experimental_poppyUserId?: string; + __experimental_poppySessionId?: string; +}; + +const getAuthAgent = (claims: JwtPayload, session?: PoppyBrowserSession | null): AuthAgent | undefined => { + const claim: unknown = claims.agt; + if (typeof claim === 'object' && claim !== null && 'id' in claim && typeof claim.id === 'string' && claim.id) { + return { + type: 'agent', + sub: claim.id, + ...('tsk' in claim && typeof claim.tsk === 'string' ? { task_id: claim.tsk } : {}), + ...(session?.account?.user_id === claims.sub && + session.account.session_id === claims.sid && + session.client_id === claim.id + ? { + __experimental_poppyClientId: session.client_id, + __experimental_poppyUserId: session.user_id, + __experimental_poppySessionId: session.session_id, + } + : {}), + }; + } + return undefined; +}; + /** * @internal */ @@ -42,6 +71,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & { * @internal */ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { + agent?: AuthAgent; /** * The allowed token type. */ @@ -65,6 +95,7 @@ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { * @internal */ export type SignedOutAuthObject = { + agent?: undefined; sessionClaims: null; sessionId: null; sessionStatus: SessionStatusClaim | null; @@ -198,6 +229,7 @@ export function signedInAuthObject( const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } = __experimental_JWTPayloadToAuthObjectProperties(sessionClaims); const apiClient = createBackendApiClient(authenticateContext); + const agent = getAuthAgent(sessionClaims, authenticateContext.poppySession); const getToken = createGetToken({ sessionId, sessionToken, @@ -206,6 +238,7 @@ export function signedInAuthObject( }); return { tokenType: TokenType.SessionToken, + ...(agent ? { agent } : {}), actor, sessionClaims, sessionId, diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts index 5282aa4b3a8..6952f22aad7 100644 --- a/packages/backend/src/tokens/authStatus.ts +++ b/packages/backend/src/tokens/authStatus.ts @@ -1,5 +1,6 @@ import type { JwtPayload, PendingSessionOptions } from '@clerk/shared/types'; +import type { PoppyBrowserSession } from '../api/endpoints/PoppyApi'; import { constants } from '../constants'; import type { TokenVerificationErrorReason } from '../errors'; import type { AuthenticateContext } from './authenticateContext'; @@ -40,6 +41,7 @@ type ToAuth = T exten : () => UnauthenticatedMachineObject>; export type AuthenticatedState = { + poppy?: PoppyBrowserSession | null; status: typeof AuthStatus.SignedIn; reason: null; message: null; @@ -63,6 +65,7 @@ export type AuthenticatedState = { }; export type UnauthenticatedState = { + poppy?: PoppyBrowserSession | null; status: typeof AuthStatus.SignedOut; reason: AuthReason; message: string; @@ -118,6 +121,7 @@ export const AuthErrorReason = { SessionTokenWithoutClientUAT: 'session-token-but-no-client-uat', ActiveOrganizationMismatch: 'active-organization-mismatch', TokenTypeMismatch: 'token-type-mismatch', + PoppyAccountInvalid: 'poppy-account-invalid', UnexpectedError: 'unexpected-error', } as const; @@ -150,7 +154,10 @@ export function signedIn(params: SignedInParams & { tokenTy const authObject = signedInAuthObject(authenticateContext, token, sessionClaims); if (treatPendingAsSignedOut && authObject.sessionStatus === 'pending') { - return signedOutAuthObject(undefined, authObject.sessionStatus); + return signedOutAuthObject( + authenticateContext.poppySession ? { poppySession: authenticateContext.poppySession } : undefined, + authObject.sessionStatus, + ); } return authObject; diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index 427b49ae7b3..f720435944d 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -3,6 +3,7 @@ import { getAutoProxyUrlFromEnvironment } from '@clerk/shared/proxy'; import type { Jwt } from '@clerk/shared/types'; import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url'; +import type { PoppyBrowserSession } from '../api/endpoints/PoppyApi'; import { constants } from '../constants'; import { decodeJwt } from '../jwt/verifyJwt'; import { runtime } from '../runtime'; @@ -13,6 +14,7 @@ import { TokenType } from './tokenTypes'; import type { AuthenticateRequestOptions } from './types'; interface AuthenticateContext extends AuthenticateRequestOptions { + poppySession?: PoppyBrowserSession | null; // header-based values accept: string | undefined; forwardedHost: string | undefined; diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 9c6afb45db2..8029a71656f 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -79,7 +79,7 @@ export async function verifyHandshakeToken( let key; if (jwtKey) { - key = loadClerkJwkFromPem({ kid, pem: jwtKey }); + key = data.header.alg === 'EdDSA' ? jwtKey : loadClerkJwkFromPem({ kid, pem: jwtKey }); } else if (secretKey) { // Fetch JWKS from Backend API using the key key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache }); diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 07f16f0a97e..eb3659a90a8 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,6 +1,9 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; import { logger } from '@clerk/shared/logger'; import type { JwtPayload } from '@clerk/shared/types'; +import type { PoppyBrowserSession, PoppyCredential } from '../api/endpoints/PoppyApi'; +import { poppyCookies } from '../api/endpoints/PoppyApi'; import { constants } from '../constants'; import type { TokenCarrier } from '../errors'; import { MachineTokenVerificationError, TokenVerificationError, TokenVerificationErrorReason } from '../errors'; @@ -150,7 +153,8 @@ export const authenticateRequest: AuthenticateRequest = (async ( request: Request, options: AuthenticateRequestOptions, ): Promise | UnauthenticatedState> => { - const authenticateContext = await createAuthenticateContext(createClerkRequest(request), options); + const clerkRequest = createClerkRequest(request); + const authenticateContext = await createAuthenticateContext(clerkRequest, options); // Default tokenType is session_token for backwards compatibility. const acceptsToken = options.acceptsToken ?? TokenType.SessionToken; @@ -895,18 +899,16 @@ export const authenticateRequest: AuthenticateRequest = (async ( } } + let state: RequestState | UnauthenticatedState; if (authenticateContext.tokenInHeader) { if (acceptsToken === 'any' || Array.isArray(acceptsToken)) { - return authenticateAnyRequestWithTokenInHeader(); - } - if (acceptsToken === TokenType.SessionToken) { - return authenticateRequestWithTokenInHeader(); + state = await authenticateAnyRequestWithTokenInHeader(); + } else if (acceptsToken === TokenType.SessionToken) { + state = await authenticateRequestWithTokenInHeader(); + } else { + state = await authenticateMachineRequestWithTokenInHeader(); } - return authenticateMachineRequestWithTokenInHeader(); - } - - // Machine requests cannot have the token in the cookie, it must be in header. - if ( + } else if ( acceptsToken === TokenType.OAuthToken || acceptsToken === TokenType.ApiKey || acceptsToken === TokenType.M2MToken @@ -916,14 +918,133 @@ export const authenticateRequest: AuthenticateRequest = (async ( authenticateContext, reason: 'No token in header', }); + } else { + state = await authenticateRequestWithTokenInCookie(); + } + + if (state.tokenType !== TokenType.SessionToken) { + return state; + } + + const rejectPoppyAccount = () => { + const rejected = signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.PoppyAccountInvalid, + message: 'Poppy browser account authority is no longer valid.', + }); + rejected.poppy = authenticateContext.poppySession; + return rejected; + }; + const resolvePoppy = async (credential: PoppyCredential): Promise => { + if (!options.apiClient) { + throw new Error('A Clerk API client is required to verify Poppy account authority.'); + } + try { + return await options.apiClient.__experimental_poppy.resolveSession(credential); + } catch (error) { + if (isClerkAPIResponseError(error) && (error.status === 401 || error.status === 404)) { + return null; + } + throw error; + } + }; + + const browserToken = clerkRequest.cookies.get(poppyCookies.browser); + let joined: PoppyBrowserSession | null = null; + if (browserToken && options.__experimental_poppy) { + const endpoint = options.__experimental_poppy.browserSessionEndpoint; + if (new URL(endpoint).origin !== clerkRequest.clerkUrl.origin) { + throw new Error('The Poppy browser Session endpoint must use the Company origin.'); + } + joined = await resolvePoppy({ type: 'browser', endpoint, token: browserToken }); + authenticateContext.poppySession = joined; + state.poppy = joined; + } + + if (state.status === 'handshake') { + const sessionToken = authenticateContext.sessionTokenInCookie; + if ( + joined && + !joined.account && + sessionToken && + !isMachineJwt(sessionToken) && + !hasNonSessionJwtCategory(sessionToken) + ) { + const { data, errors } = await verifyToken(sessionToken, authenticateContext); + let claims = data; + if (errors) { + const error: unknown = errors[0]; + if (!(error instanceof TokenVerificationError)) { + throw error instanceof Error ? error : new Error('Native Clerk Session verification failed.'); + } + switch (error.reason) { + case TokenVerificationErrorReason.TokenExpired: + claims = decodeJwt(sessionToken).data?.payload; + break; + case TokenVerificationErrorReason.TokenInvalid: + case TokenVerificationErrorReason.TokenInvalidAlgorithm: + case TokenVerificationErrorReason.TokenInvalidAuthorizedParties: + case TokenVerificationErrorReason.TokenInvalidIssuer: + case TokenVerificationErrorReason.TokenInvalidSignature: + case TokenVerificationErrorReason.TokenNotActiveYet: + case TokenVerificationErrorReason.TokenIatInTheFuture: + return state; + default: + throw error; + } + } + const agent: unknown = claims?.agt; + if (typeof claims?.sid === 'string' && typeof agent === 'object' && agent !== null && 'poppy' in agent) { + return rejectPoppyAccount(); + } + } + if ( + state.reason === AuthErrorReason.DevBrowserMissing && + joined && + !joined.account && + !authenticateContext.sessionToken && + !authenticateContext.clientUat && + !authenticateContext.refreshTokenInCookie + ) { + const guest = signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.SessionTokenAndUATMissing, + }); + guest.poppy = joined; + return guest; + } + return state; } - return authenticateRequestWithTokenInCookie(); + if (!state.isAuthenticated) { + return state; + } + const auth = state.toAuth(); + if (auth.tokenType !== TokenType.SessionToken) { + return state; + } + if (joined?.account && (joined.account.session_id !== auth.sessionId || joined.account.user_id !== auth.userId)) { + return rejectPoppyAccount(); + } + const agent: unknown = auth.sessionClaims.agt; + const isPoppySession = typeof agent === 'object' && agent !== null && 'poppy' in agent; + if (!isPoppySession) { + return state; + } + const current = + browserToken && options.__experimental_poppy ? joined : await resolvePoppy({ type: 'session', token: state.token }); + if (!current?.account || current.account.session_id !== auth.sessionId || current.account.user_id !== auth.userId) { + return rejectPoppyAccount(); + } + if (!joined) { + authenticateContext.poppySession = current; + state.poppy = current; + } + return state; }) as AuthenticateRequest; -/** - * @internal - */ export const debugRequestState = (params: RequestState) => { const { isSignedIn, isAuthenticated, proxyUrl, reason, message, publishableKey, isSatellite, domain } = params; return { isSignedIn, isAuthenticated, proxyUrl, reason, message, publishableKey, isSatellite, domain }; diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index f54724da76b..aafde10a6ec 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -2,6 +2,7 @@ import type { MatchFunction } from '@clerk/shared/pathToRegexp'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { ApiClient, APIKey, IdPOAuthAccessToken, M2MToken } from '../api'; +import type { PoppyOptions } from '../api/endpoints/PoppyApi'; import type { AuthenticatedMachineObject, AuthObject, @@ -17,6 +18,12 @@ import type { VerifyTokenOptions } from './verify'; * @interface */ export type AuthenticateRequestOptions = { + /** + * Clerk's experimental configuration for Poppy browser sessions. Enables live account-authority + * checks for the joined browser; in `clerkMiddleware()`, also serves discovery and join requests. + * These SDK options may change while Poppy is a draft. + */ + __experimental_poppy?: PoppyOptions; /** * The Clerk Publishable Key from the [**API keys**](https://dashboard.clerk.com/~/api-keys) page in the Clerk Dashboard. */ diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index b20387f93d4..3aa451ed2b6 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -135,10 +135,10 @@ export async function verifyToken( } try { - let key: JsonWebKey; + let key: JsonWebKey | string; if (options.jwtKey) { - key = loadClerkJwkFromPem({ kid, pem: options.jwtKey }); + key = decodedResult.header.alg === 'EdDSA' ? options.jwtKey : loadClerkJwkFromPem({ kid, pem: options.jwtKey }); } else if (options.secretKey) { key = await loadClerkJWKFromRemote({ ...options, kid }); } else { diff --git a/packages/nextjs/src/app-router/client/PoppyCallback.tsx b/packages/nextjs/src/app-router/client/PoppyCallback.tsx new file mode 100644 index 00000000000..357df87b310 --- /dev/null +++ b/packages/nextjs/src/app-router/client/PoppyCallback.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useSignIn } from '@clerk/react/legacy'; +import React, { useEffect, useRef, useState } from 'react'; + +export function PoppyCallback({ ticket, redirectTo }: { ticket: string; redirectTo: string }) { + const { isLoaded, signIn, setActive } = useSignIn(); + const hasRun = useRef(false); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!isLoaded || hasRun.current) { + return; + } + hasRun.current = true; + void (async () => { + try { + const result = await signIn.create({ strategy: 'ticket', ticket }); + if (result.status !== 'complete' || !result.createdSessionId) { + setFailed(true); + return; + } + await setActive({ session: result.createdSessionId, redirectUrl: redirectTo }); + } catch { + setFailed(true); + } + })(); + }, [isLoaded, signIn, setActive, ticket, redirectTo]); + + return

{failed ? 'Start the browser connection again.' : 'Connecting this browser.'}

; +} diff --git a/packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx b/packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx new file mode 100644 index 00000000000..083a6e4cb72 --- /dev/null +++ b/packages/nextjs/src/app-router/client/__tests__/PoppyCallback.test.tsx @@ -0,0 +1,70 @@ +import { useSignIn } from '@clerk/react/legacy'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import React, { StrictMode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PoppyCallback } from '../PoppyCallback'; + +vi.mock('@clerk/react/legacy', () => ({ useSignIn: vi.fn() })); + +const create = vi.fn(); +const setActive = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + create.mockResolvedValue({ status: 'complete', createdSessionId: 'sess_agent' }); + setActive.mockResolvedValue(undefined); + vi.mocked(useSignIn, { partial: true, deep: true }).mockReturnValue({ + isLoaded: true, + signIn: { create }, + setActive, + }); +}); + +afterEach(cleanup); + +describe('PoppyCallback', () => { + it('redeems the ticket once through normal sign-in and activates its Session', async () => { + render( + + + , + ); + await waitFor(() => + expect(setActive).toHaveBeenCalledWith({ session: 'sess_agent', redirectUrl: 'https://company.example/cart' }), + ); + expect(create).toHaveBeenCalledExactlyOnceWith({ strategy: 'ticket', ticket: 'one-use-ticket' }); + expect(setActive).toHaveBeenCalledTimes(1); + expect(document.location.href).not.toContain('one-use-ticket'); + }); + + it.each([ + { status: 'needs_second_factor', createdSessionId: null }, + { status: 'complete', createdSessionId: null }, + ])('does not activate incomplete sign-in $status', async result => { + create.mockResolvedValue(result); + render( + , + ); + await screen.findByText('Start the browser connection again.'); + expect(setActive).not.toHaveBeenCalled(); + }); + + it('shows a restart action when ticket redemption fails', async () => { + create.mockRejectedValue(new Error('Ticket expired')); + render( + , + ); + await screen.findByText('Start the browser connection again.'); + expect(setActive).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/src/server/PoppyCallbackPage.tsx b/packages/nextjs/src/server/PoppyCallbackPage.tsx new file mode 100644 index 00000000000..13df50eaca4 --- /dev/null +++ b/packages/nextjs/src/server/PoppyCallbackPage.tsx @@ -0,0 +1,31 @@ +import { poppyCookies } from '@clerk/backend'; +import { constants } from '@clerk/backend/internal'; +import { cookies, headers } from 'next/headers'; +import React from 'react'; + +import { PoppyCallback } from '../app-router/client/PoppyCallback'; +import { decryptClerkRequestData } from './utils'; + +export async function PoppyCallbackPage() { + const requestHeaders = await headers(); + const options = decryptClerkRequestData(requestHeaders.get(constants.Headers.ClerkRequestData)); + const endpoint = options.__experimental_poppy && new URL(options.__experimental_poppy.browserSessionEndpoint); + const currentUrl = requestHeaders.get(constants.Headers.ClerkUrl); + if (!endpoint || !currentUrl || new URL(currentUrl).href !== endpoint.href + '/callback') { + throw new Error('The Poppy callback requires its configured Clerk middleware route.'); + } + const ticket = (await cookies()).get(poppyCookies.ticket)?.value; + if (!ticket) { + return

Start the browser connection again.

; + } + const redirect = new URL(options.__experimental_poppy?.redirectTo ?? '/', endpoint); + if (redirect.origin !== endpoint.origin || redirect.username || redirect.password) { + throw new Error('The browser Session redirect must stay on the Company origin.'); + } + return ( + + ); +} diff --git a/packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx b/packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx new file mode 100644 index 00000000000..b42e0f6dc8d --- /dev/null +++ b/packages/nextjs/src/server/__tests__/PoppyCallbackPage.test.tsx @@ -0,0 +1,40 @@ +import { constants } from '@clerk/backend/internal'; +import { cookies, headers } from 'next/headers'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PoppyCallbackPage } from '../PoppyCallbackPage'; +import { decryptClerkRequestData } from '../utils'; + +vi.mock('next/headers', () => ({ headers: vi.fn(), cookies: vi.fn() })); +vi.mock('../utils', () => ({ decryptClerkRequestData: vi.fn() })); + +const endpoint = 'https://company.example/poppy/browser-session'; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(decryptClerkRequestData).mockReturnValue({ + __experimental_poppy: { browserSessionEndpoint: endpoint, redirectTo: '/cart' }, + }); + vi.mocked(headers).mockResolvedValue(new Headers({ [constants.Headers.ClerkUrl]: endpoint + '/callback' })); + vi.mocked(cookies, { partial: true }).mockResolvedValue({ + get: vi.fn().mockReturnValue({ value: 'one-use-ticket' }), + }); +}); + +describe('PoppyCallbackPage', () => { + it('passes the protected ticket to the native sign-in callback without URL transport', async () => { + const page = await PoppyCallbackPage(); + expect(page.props).toEqual({ ticket: 'one-use-ticket', redirectTo: 'https://company.example/cart' }); + }); + + it('requires a configured middleware callback', async () => { + vi.mocked(decryptClerkRequestData).mockReturnValue({}); + await expect(PoppyCallbackPage()).rejects.toThrow('configured Clerk middleware'); + }); + + it('shows a restart action when the ticket cookie is missing', async () => { + vi.mocked(cookies, { partial: true }).mockResolvedValue({ get: vi.fn() }); + const page = await PoppyCallbackPage(); + expect(page.props.children).toBe('Start the browser connection again.'); + }); +}); diff --git a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap index fcbeab2a164..3c7af9e6af4 100644 --- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap @@ -2,6 +2,7 @@ exports[`/server public exports > should not include a breaking change 1`] = ` [ + "PoppyCallbackPage", "auth", "buildClerkProps", "clerkClient", diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 3323bf74e9f..ddf28022b2b 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -1,6 +1,7 @@ // There is no need to execute the complete authenticateRequest to test clerkMiddleware // This mock SHOULD exist before the import of authenticateRequest import { authenticatedMachineObject, AuthStatus, constants, TokenType } from '@clerk/backend/internal'; +import { ClerkAPIResponseError } from '@clerk/shared/error'; import { MalformedURLError } from '@clerk/shared/pathMatcher'; // used to assert the mock import assert from 'assert'; @@ -11,6 +12,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, expectTypeOf, it, ty import { clerkClient } from '../clerkClient'; import type { ClerkMiddlewareAuth } from '../clerkMiddleware'; import { clerkMiddleware } from '../clerkMiddleware'; +import { getSessionAuthDataFromRequest } from '../data/getAuthDataFromRequest'; import { createRouteMatcher } from '../routeMatcher'; import { decryptClerkRequestData } from '../utils'; @@ -1535,3 +1537,320 @@ describe('contentSecurityPolicy option', () => { expect(requestCSP).toEqual(cspHeader); }); }); + +describe('Poppy middleware', () => { + const endpoint = 'https://company.example/poppy/browser-session'; + + it('returns the Poppy scope challenge without running the application or changing cookies', async () => { + authenticateRequestMock.mockRejectedValueOnce( + new ClerkAPIResponseError('Scope required', { + status: 403, + data: [{ code: 'insufficient_scope', message: 'Scope required' }], + }), + ); + const handler = vi.fn(); + const middleware = clerkMiddleware(handler, { __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const response = await middleware( + mockRequest({ + url: 'https://company.example/', + headers: { cookie: '__Host-poppy_session=browser-token; __session=native-session' }, + }), + {} as NextFetchEvent, + ); + assert(response instanceof Response); + expect(response.status).toBe(403); + expect(response.headers.get('www-authenticate')).toBe( + 'Bearer error="insufficient_scope", scope="poppy:browser-sign-in"', + ); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8'); + expect(await response.text()).toBe('Additional authorization is required. Missing scope: poppy:browser-sign-in.'); + expect(response.headers.getSetCookie()).toEqual([]); + expect(response.headers.has('x-middleware-set-cookie')).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it.each([ + { name: 'Poppy is disabled', enabled: false, status: 403, code: 'insufficient_scope' }, + { name: 'a different forbidden error', enabled: true, status: 403, code: 'origin_invalid' }, + { name: 'an infrastructure failure', enabled: true, status: 503, code: 'insufficient_scope' }, + { name: 'an invalid token', enabled: true, status: 401, code: 'invalid_token' }, + ])('propagates authentication errors unchanged for $name', async ({ enabled, status, code }) => { + const failure = new ClerkAPIResponseError('Authentication failed', { + status, + data: [{ code, message: 'Authentication failed' }], + }); + authenticateRequestMock.mockRejectedValueOnce(failure); + const middleware = clerkMiddleware(enabled ? { __experimental_poppy: { browserSessionEndpoint: endpoint } } : {}); + await expect(middleware(mockRequest({ url: 'https://company.example/' }), {} as NextFetchEvent)).rejects.toBe( + failure, + ); + }); + + const metadata = { + protocol_version: '0.4', + organization: { name: 'Company', domain: 'company.example' }, + auth: { + issuer: 'https://clerk.company.example', + scopes: { 'poppy:browser-sign-in': 'Full website access', profile: 'Read profile' }, + }, + web: { browser_session_endpoint: endpoint, unscoped: true }, + extension: { unchanged: true }, + }; + const { web: _web, ...partialMetadata } = metadata; + + it.each(['/.well-known/poppy.json', '/.well-known/oauth-protected-resource'])( + 'serves %s from the Backend API artifact before authentication', + async path => { + const getMetadata = vi.fn().mockResolvedValue(partialMetadata); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const response = await middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent); + assert(response instanceof Response); + expect(response.status).toBe(200); + expect(await response.json()).toEqual( + path === '/.well-known/poppy.json' + ? metadata + : { + resource: 'https://company.example', + authorization_servers: ['https://clerk.company.example'], + scopes_supported: ['poppy:browser-sign-in', 'profile'], + bearer_methods_supported: ['header'], + }, + ); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(getMetadata).toHaveBeenCalledExactlyOnceWith(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { path: '/.well-known/poppy.json', method: 'POST' }, + { path: '/.well-known/poppy.json', method: 'HEAD' }, + { path: '/.well-known/oauth-protected-resource', method: 'POST' }, + { path: '/.well-known/oauth-protected-resource', method: 'OPTIONS' }, + ])('rejects discovery $method $path without fetching the artifact', async ({ path, method }) => { + const getMetadata = vi.fn(); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const response = await middleware( + mockRequest({ url: 'https://company.example' + path, method }), + {} as NextFetchEvent, + ); + assert(response instanceof Response); + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('GET'); + expect(getMetadata).not.toHaveBeenCalled(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it.each(['/.well-known/poppy.json', '/.well-known/oauth-protected-resource'])( + 'keeps %s available to the application when Poppy is off', + async path => { + const getMetadata = vi.fn(); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + ); + const handler = vi.fn().mockResolvedValue(new Response('application discovery')); + const middleware = clerkMiddleware(handler); + const response = await middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent); + assert(response instanceof Response); + expect(await response.text()).toBe('application discovery'); + expect(getMetadata).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledOnce(); + expect(authenticateRequestMock).toHaveBeenCalledOnce(); + }, + ); + + it('requires the configured Company origin for discovery', async () => { + const getMetadata = vi.fn(); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + await middleware(mockRequest({ url: 'https://other.example/.well-known/poppy.json' }), {} as NextFetchEvent); + expect(getMetadata).not.toHaveBeenCalled(); + expect(authenticateRequestMock).toHaveBeenCalledOnce(); + }); + + it('serves discovery for the configured Company host behind a reverse proxy', async () => { + const getMetadata = vi.fn().mockResolvedValue(partialMetadata); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const response = await middleware( + mockRequest({ + url: 'http://localhost:3000/.well-known/poppy.json', + headers: { host: 'company.example', 'x-forwarded-proto': 'https' }, + }), + {} as NextFetchEvent, + ); + assert(response instanceof Response); + expect(await response.json()).toEqual(metadata); + expect(getMetadata).toHaveBeenCalledExactlyOnceWith(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it('rejects discovery when the configured endpoint differs from the artifact origin', async () => { + const getMetadata = vi.fn().mockResolvedValue({ + ...partialMetadata, + organization: { ...partialMetadata.organization, domain: 'other.example' }, + }); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + await expect( + middleware(mockRequest({ url: 'https://company.example/.well-known/poppy.json' }), {} as NextFetchEvent), + ).rejects.toThrow('configured Company origin'); + }); + + it.each(['/.well-known/poppy.json', '/.well-known/oauth-protected-resource'])( + 'propagates an artifact failure for %s without fabricated discovery', + async path => { + const failure = new Error('Backend artifact unavailable'); + const getMetadata = vi.fn().mockRejectedValue(failure); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { getMetadata } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + await expect( + middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent), + ).rejects.toBe(failure); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { path: '', method: 'GET' }, + { path: '', method: 'POST' }, + { path: '/authorize', method: 'POST' }, + ])('forwards protocol $method $path before native authentication', async ({ path, method }) => { + const forwarded = new Response('protocol-response', { status: 400, headers: { 'Poppy-Version': '0.4' } }); + const proxyBrowserRequest = vi.fn().mockResolvedValue(forwarded); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { proxyBrowserRequest } }), + ); + const request = mockRequest({ url: endpoint + path, method }); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const response = await middleware(request, {} as NextFetchEvent); + expect(response).toBe(forwarded); + expect(proxyBrowserRequest).toHaveBeenCalledWith(request, { browserSessionEndpoint: endpoint }); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it('recognizes the configured Company host behind a reverse proxy', async () => { + const forwarded = new Response('protocol-response'); + const proxyBrowserRequest = vi.fn().mockResolvedValue(forwarded); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { __experimental_poppy: { proxyBrowserRequest } }), + ); + const request = mockRequest({ + url: 'http://localhost:3000/poppy/browser-session', + headers: { host: 'company.example', 'x-forwarded-proto': 'https' }, + }); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + expect(await middleware(request, {} as NextFetchEvent)).toBe(forwarded); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it('clears only the ticket response cookie and renders the callback without a native handshake', async () => { + const request = mockRequest({ + url: endpoint + '/callback', + headers: { cookie: '__Host-poppy_ticket=one-use-ticket; __session=prior-session' }, + }); + const middleware = clerkMiddleware({ + __experimental_poppy: { browserSessionEndpoint: endpoint, redirectTo: '/cart' }, + contentSecurityPolicy: { strict: true }, + }); + const response = await middleware(request, {} as NextFetchEvent); + assert(response instanceof Response); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('content-security-policy')).toContain("'strict-dynamic'"); + expect(response.headers.get('x-middleware-request-content-security-policy')).toBe( + response.headers.get('content-security-policy'), + ); + expect(response.headers.get('x-middleware-request-x-nonce')).toBe(response.headers.get('x-nonce')); + expect(response.headers.get('x-nonce')).toBeTruthy(); + expect(response.headers.has('x-middleware-set-cookie')).toBe(false); + expect(response.headers.getSetCookie()).toHaveLength(1); + expect(response.headers.getSetCookie()[0]).toContain('__Host-poppy_ticket=;'); + expect(response.headers.getSetCookie()[0]).toContain('Max-Age=0'); + expect(response.headers.get('x-middleware-request-cookie')).toContain('__Host-poppy_ticket=one-use-ticket'); + const data = decryptClerkRequestData( + response.headers.get('x-middleware-request-' + constants.Headers.ClerkRequestData), + ); + expect(data.__experimental_poppy).toEqual({ browserSessionEndpoint: endpoint, redirectTo: '/cart' }); + expect(data.secretKey).toBeUndefined(); + }); + + it.each([true, false])('exposes verified identity only on an authenticated agent; signed in=%s', async signedIn => { + const identity = { + session_id: 'protocol-session', + client_id: 'https://agent.example', + user_id: 'pseudonym', + ...(signedIn + ? { account: { user_id: 'user_123', session_id: 'sess_123', scopes: ['poppy:browser-sign-in'] } } + : {}), + }; + const token = [ + JSON.stringify({ alg: 'RS256', typ: 'JWT' }), + JSON.stringify({ sub: 'user_123', sid: 'sess_123', agt: { id: identity.client_id }, iat: 0, exp: 9999999999 }), + 'signature', + ] + .map(part => Buffer.from(part).toString('base64url')) + .join('.'); + authenticateRequestMock.mockResolvedValueOnce({ + status: signedIn ? AuthStatus.SignedIn : AuthStatus.SignedOut, + token: signedIn ? token : null, + tokenType: TokenType.SessionToken, + poppy: identity, + toAuth: () => ({ + tokenType: TokenType.SessionToken, + isAuthenticated: signedIn, + userId: signedIn ? 'user_123' : null, + debug: vi.fn(), + }), + headers: new Headers(), + publishableKey, + }); + const middleware = clerkMiddleware({ __experimental_poppy: { browserSessionEndpoint: endpoint } }); + const response = await middleware(mockRequest({ url: 'https://company.example/cart' }), {} as NextFetchEvent); + assert(response instanceof Response); + const data = decryptClerkRequestData( + response.headers.get('x-middleware-request-' + constants.Headers.ClerkRequestData), + ); + expect(data.poppySession).toEqual(identity); + const serverHeaders = new Headers(); + response.headers.forEach((value, key) => { + if (key.startsWith('x-middleware-request-')) { + serverHeaders.set(key.slice('x-middleware-request-'.length), value); + } + }); + const appAuth = getSessionAuthDataFromRequest( + new NextRequest('https://company.example/cart', { headers: serverHeaders }), + ); + expect(appAuth.isAuthenticated).toBe(signedIn); + expect(Object.keys(appAuth).filter(key => key.startsWith('__experimental_poppy'))).toEqual([]); + if (signedIn) { + expect(appAuth.agent).toEqual({ + type: 'agent', + sub: identity.client_id, + __experimental_poppyClientId: identity.client_id, + __experimental_poppyUserId: identity.user_id, + __experimental_poppySessionId: identity.session_id, + }); + expectTypeOf(appAuth.agent?.__experimental_poppySessionId).toEqualTypeOf(); + } else { + expect(appAuth).not.toHaveProperty('agent'); + } + expect(appAuth.userId).toBe(signedIn ? 'user_123' : null); + expect(appAuth).not.toHaveProperty('poppy'); + }); +}); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index a0e0fb9ceeb..cef54ac660f 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -1,4 +1,5 @@ import type { AuthObject, ClerkClient } from '@clerk/backend'; +import { createPoppyBrowserSessionHandler, poppyCookies } from '@clerk/backend'; import type { AuthenticatedState, AuthenticateRequestOptions, @@ -20,6 +21,7 @@ import { TokenType, } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; import { isProductionFromPublishableKey, parsePublishableKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import { isMalformedURLError } from '@clerk/shared/pathMatcher'; @@ -61,6 +63,7 @@ import type { import { assertKey, decorateRequest, + encryptClerkRequestData, handleMultiDomainAndProxy, redirectAdapter, setRequestHeadersOnNextResponse, @@ -206,6 +209,79 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl return clerkMiddlewareRequestDataStorage.run(requestDataStore, async () => { const resolvedClerkClient = await clerkClient(); + if (options.__experimental_poppy) { + const proxyPoppy = createPoppyBrowserSessionHandler({ + ...options.__experimental_poppy, + clerkClient: resolvedClerkClient, + }); + const endpoint = new URL(options.__experimental_poppy.browserSessionEndpoint); + const poppyRequestUrl = createClerkRequest(request).clerkUrl; + if (poppyRequestUrl.origin === endpoint.origin) { + if ( + poppyRequestUrl.pathname === '/.well-known/poppy.json' || + poppyRequestUrl.pathname === '/.well-known/oauth-protected-resource' + ) { + if (request.method !== 'GET') { + return new Response(null, { status: 405, headers: { Allow: 'GET' } }); + } + const metadata = await resolvedClerkClient.__experimental_poppy.getMetadata(); + if (endpoint.origin !== `https://${metadata.organization.domain}`) { + throw new Error('The browser Session endpoint must use the configured Company origin.'); + } + return Response.json( + poppyRequestUrl.pathname === '/.well-known/poppy.json' + ? { ...metadata, web: { browser_session_endpoint: endpoint.href, unscoped: true } } + : { + resource: endpoint.origin, + authorization_servers: [metadata.auth.issuer], + scopes_supported: Object.keys(metadata.auth.scopes), + bearer_methods_supported: ['header'], + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } + if ( + (poppyRequestUrl.pathname === endpoint.pathname && + (request.method === 'GET' || request.method === 'POST')) || + (poppyRequestUrl.pathname === endpoint.pathname + '/authorize' && request.method === 'POST') + ) { + return proxyPoppy(request); + } + if (poppyRequestUrl.pathname === endpoint.pathname + '/callback' && request.method === 'GET') { + const response = NextResponse.next(); + if (options.contentSecurityPolicy) { + const { headers } = createContentSecurityPolicyHeaders( + (parsePublishableKey(publishableKey)?.frontendApi ?? '').replace('$', ''), + options.contentSecurityPolicy, + ); + const cspRequestHeaders: Record = {}; + headers.forEach(([key, value]) => { + setHeader(response, key, value); + cspRequestHeaders[key] = value; + }); + setRequestHeadersOnNextResponse(response, request, cspRequestHeaders); + } + response.headers.set('Cache-Control', 'no-store'); + response.headers.set('Referrer-Policy', 'no-referrer'); + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.append( + 'Set-Cookie', + `${poppyCookies.ticket}=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict`, + ); + setRequestHeadersOnNextResponse(response, request, { + [constants.Headers.AuthStatus]: AuthStatus.SignedOut, + [constants.Headers.AuthToken]: '', + [constants.Headers.AuthSignature]: '', + [constants.Headers.AuthReason]: '', + [constants.Headers.AuthMessage]: '', + [constants.Headers.ClerkUrl]: poppyRequestUrl.href, + [constants.Headers.ClerkRequestData]: encryptClerkRequestData(resolvedParams, {}, null) ?? '', + }); + return response; + } + } + } + if (options.debug) { logger.enable(); } @@ -225,10 +301,29 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl })); } - const requestState = await resolvedClerkClient.authenticateRequest( - clerkRequest, - createAuthenticateRequestOptions(clerkRequest, options), - ); + const requestState = await resolvedClerkClient + .authenticateRequest(clerkRequest, createAuthenticateRequestOptions(clerkRequest, options)) + .catch((error: unknown) => { + if ( + !options.__experimental_poppy || + !isClerkAPIResponseError(error) || + error.status !== 403 || + error.errors[0]?.code !== 'insufficient_scope' + ) { + throw error; + } + return new NextResponse('Additional authorization is required. Missing scope: poppy:browser-sign-in.', { + status: 403, + headers: { + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="poppy:browser-sign-in"', + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); + }); + if (requestState instanceof NextResponse) { + return requestState; + } return runHandlerWithRequestState({ clerkRequest, diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index 348e7f277e2..0656d610b29 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -60,6 +60,7 @@ const createAuthOptions = (req: RequestLike, opts: GetAuthDataFromRequestOptions authMessage: getAuthKeyFromRequest(req, 'AuthMessage'), authReason: getAuthKeyFromRequest(req, 'AuthReason'), treatPendingAsSignedOut, + poppySession: decryptedRequestData.poppySession, }; }; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 477ad2eac59..0dde4a959b2 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -51,6 +51,8 @@ export type { ClerkMiddlewareAuth, ClerkMiddlewareSessionAuthObject, ClerkMiddle */ export type { OrganizationMembershipRole, + PoppyBrowserSession, + PoppyOptions, // Resources AllowlistIdentifier, Client, @@ -85,3 +87,5 @@ export { type FrontendApiProxyOptions, type NextFrontendApiProxyOptions, } from './proxy'; + +export { PoppyCallbackPage } from './PoppyCallbackPage'; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index e6504478823..62f297331d0 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -1,4 +1,4 @@ -import type { AuthObject } from '@clerk/backend'; +import type { AuthObject, PoppyBrowserSession } from '@clerk/backend'; import type { AuthenticateRequestOptions, ClerkRequest, RequestState } from '@clerk/backend/internal'; import { constants } from '@clerk/backend/internal'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; @@ -89,7 +89,7 @@ export function decorateRequest( } if (rewriteURL) { - const clerkRequestData = encryptClerkRequestData(requestData, keylessMode, machineAuthObject); + const clerkRequestData = encryptClerkRequestData(requestData, keylessMode, machineAuthObject, requestState.poppy); setRequestHeadersOnNextResponse(res, req, { [constants.Headers.AuthStatus]: status, @@ -210,6 +210,7 @@ export function encryptClerkRequestData( requestData: Partial, keylessModeKeys: Pick, machineAuthObject: AuthObject | null, + poppySession?: PoppyBrowserSession | null, ) { const isEmpty = (obj: Record | undefined) => { if (!obj) { @@ -218,7 +219,7 @@ export function encryptClerkRequestData( return !Object.values(obj).some(v => v !== undefined); }; - if (isEmpty(requestData) && isEmpty(keylessModeKeys) && !machineAuthObject) { + if (isEmpty(requestData) && isEmpty(keylessModeKeys) && !machineAuthObject && !poppySession) { return; } @@ -231,7 +232,12 @@ export function encryptClerkRequestData( : ENCRYPTION_KEY || SECRET_KEY || KEYLESS_ENCRYPTION_KEY; return AES.encrypt( - JSON.stringify({ ...keylessModeKeys, ...requestData, machineAuthObject: machineAuthObject ?? undefined }), + JSON.stringify({ + ...keylessModeKeys, + ...requestData, + machineAuthObject: machineAuthObject ?? undefined, + poppySession, + }), maybeKeylessEncryptionKey, ).toString(); } @@ -242,7 +248,7 @@ export function encryptClerkRequestData( */ export function decryptClerkRequestData( encryptedRequestData?: string | undefined | null, -): Partial & { machineAuthObject?: AuthObject } { +): Partial & { machineAuthObject?: AuthObject; poppySession?: PoppyBrowserSession | null } { if (!encryptedRequestData) { return {}; }