diff --git a/.changeset/poppy-browser-session-v04.md b/.changeset/poppy-browser-session-v04.md new file mode 100644 index 00000000000..0841c223a63 --- /dev/null +++ b/.changeset/poppy-browser-session-v04.md @@ -0,0 +1,10 @@ +--- +"@clerk/backend": minor +"@clerk/nextjs": minor +--- + +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. + +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. + +Fetch the Poppy JSON artifact with the Clerk-defined `clerkClient.artifacts.__experimental_getPoppyJson()` method. diff --git a/packages/backend/src/api/__tests__/ArtifactsApi.test.ts b/packages/backend/src/api/__tests__/ArtifactsApi.test.ts new file mode 100644 index 00000000000..bc8af5ef429 --- /dev/null +++ b/packages/backend/src/api/__tests__/ArtifactsApi.test.ts @@ -0,0 +1,50 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('ArtifactsAPI', () => { + const frontendApi = 'https://clerk.company.example'; + + 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 { artifacts } = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_live_example', + }); + await expect(artifacts.__experimental_getPoppyJson()).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 { artifacts } = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'sk_live_example', + }); + await expect(artifacts.__experimental_getPoppyJson()).rejects.toMatchObject({ status }); + }); +}); 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/factory.ts b/packages/backend/src/api/factory.ts index c9b6f700f0f..c5ac0bd2b36 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -35,6 +35,7 @@ import { WaitlistEntryAPI, WebhookAPI, } from './endpoints'; +import { ArtifactsAPI } from './endpoints/ArtifactsApi'; import { BillingAPI } from './endpoints/BillingApi'; import { buildRequest } from './request'; @@ -63,6 +64,7 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { skipApiVersionInUrl: true, }), ), + artifacts: new ArtifactsAPI(request), betaFeatures: new BetaFeaturesAPI(request), blocklistIdentifiers: new BlocklistIdentifierAPI(request), /** diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index fde4ec53c75..cc62c8212c2 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -1,13 +1,16 @@ -import type { JwtPayload } from '@clerk/shared/types'; -import { describe, expect, it, vi } from 'vitest'; +import type { AgentActClaim, JwtPayload } from '@clerk/shared/types'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; 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 +34,71 @@ describe('makeAuthObjectSerializable', () => { }); describe('signedInAuthObject', () => { + it.each([ + { name: 'ordinary Session has no agent', claims: {}, expected: undefined }, + { + name: 'impersonation remains actor attribution', + claims: { act: { sub: 'impersonator' } }, + expected: undefined, + }, + { + name: 'agent-task Session uses the existing Session agent shape', + claims: { agt: { id: 'agent_123', tsk: 'agtsk_123', reg: 'https://clerk.com' } }, + expected: { type: 'agent', sub: 'agent_123', task_id: 'agtsk_123' }, + }, + { + 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' } }, + expected: undefined, + }, + ])('$name', ({ claims, expected }) => { + const auth = signedInAuthObject({}, 'token', { + ...mockJwtPayload, + __raw: 'token', + ...claims, + sub: 'user_123', + sid: 'sess_123', + }); + + expect(auth.agent).toEqual(expected); + 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('omits agent attribution from signed-out auth', async () => { + const auth = signedOutAuthObject(); + + expect(auth).not.toHaveProperty('agent'); + 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(); + }); + + 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: 'https://agent.example' } }, + }), + ), + {}, + ); + + expect(auth).not.toHaveProperty('agent'); + 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..a5c5a96ff99 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -7,8 +7,10 @@ import { mockInvalidSignatureJwt, mockJwks, mockJwt, + mockJwtHeader, mockJwtPayload, mockM2MJwtPayload, + pemEncodedPublicKey, signingJwks, } from '../../fixtures'; import { @@ -19,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'; @@ -509,6 +512,122 @@ describe('tokens.authenticateRequest(options)', () => { vi.clearAllMocks(); }); + describe('agent Session JWT authentication', () => { + const identity = { session_id: 'protocol-session', client_id: 'https://agent.example', user_id: 'pseudonym' }; + + 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, + ...(expired ? { exp: mockJwtPayload.iat - 10 } : {}), + agt: { id: identity.client_id, poppy: identity }, + }, + signingJwks, + { algorithm: 'RS256', header: { ...mockJwtHeader } }, + ); + const fetch = vi.spyOn(globalThis, 'fetch'); + const request = cookie + ? mockRequestWithCookies({}, { __session: jwt, __client_uat: mockJwtPayload.iat }) + : mockRequestWithHeaderAuth({ authorization: `Bearer ${jwt}` }); + const state = await authenticateRequest( + request, + mockOptions({ jwtKey: pemEncodedPublicKey, publishableKey: PK_LIVE }), + ); + 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, + }, + }); + } + 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 authenticated agent identity', async () => { + const state = await authenticateRequest( + mockRequestWithCookies({}, { '__Host-poppy_session': 'untrusted' }), + mockOptions({ jwtKey: pemEncodedPublicKey, publishableKey: PK_LIVE, __experimental_poppy: true }), + ); + expect(state.isAuthenticated).toBe(false); + expect(state.toAuth()).toMatchObject({ userId: null, sessionId: null }); + expect(state.toAuth()).not.toHaveProperty('agent'); + }); + }); + // // HTTP Authorization exists // diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 44391b388da..4e0634f2bd2 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, @@ -31,6 +32,18 @@ type AuthObjectDebug = () => AuthObjectDebugData; type Claims = Record; +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 } : {}), + }; + } + return undefined; +}; + /** * @internal */ @@ -42,6 +55,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & { * @internal */ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { + agent?: AgentActClaim; /** * The allowed token type. */ @@ -65,6 +79,7 @@ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & { * @internal */ export type SignedOutAuthObject = { + agent?: undefined; sessionClaims: null; sessionId: null; sessionStatus: SessionStatusClaim | null; @@ -198,6 +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); const getToken = createGetToken({ sessionId, sessionToken, @@ -206,6 +222,7 @@ export function signedInAuthObject( }); return { tokenType: TokenType.SessionToken, + ...(agent ? { agent } : {}), actor, sessionClaims, sessionId, diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index f54724da76b..aa85cf06193 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -17,6 +17,12 @@ import type { VerifyTokenOptions } from './verify'; * @interface */ export type AuthenticateRequestOptions = { + /** + * 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?: 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/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 3323bf74e9f..0d581f1b1ed 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,206 @@ describe('contentSecurityPolicy option', () => { expect(requestCSP).toEqual(cspHeader); }); }); + +describe('Poppy middleware', () => { + const endpoint = 'https://clerk.company.example/v1/poppy/browser_sessions'; + + 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' }, + { 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: true } : {}); + 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 }, + }; + + it.each(['/.well-known/poppy.json', '/.well-known/oauth-protected-resource'])( + 'serves %s from the Backend API artifact before authentication', + async path => { + const __experimental_getPoppyJson = vi.fn().mockResolvedValue(metadata); + vi.mocked(clerkClient).mockResolvedValue( + 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); + 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(__experimental_getPoppyJson).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 __experimental_getPoppyJson = vi.fn(); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: true }); + 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(__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 __experimental_getPoppyJson = vi.fn(); + vi.mocked(clerkClient).mockResolvedValue( + 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(__experimental_getPoppyJson).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledOnce(); + expect(authenticateRequestMock).toHaveBeenCalledOnce(); + }, + ); + + it('serves discovery for the configured Company host behind a reverse proxy', async () => { + const __experimental_getPoppyJson = vi.fn().mockResolvedValue(metadata); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: true }); + 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(__experimental_getPoppyJson).toHaveBeenCalledExactlyOnceWith(); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }); + + it('rejects discovery on a different Company origin', async () => { + const __experimental_getPoppyJson = vi.fn().mockResolvedValue({ + ...metadata, + organization: { ...metadata.organization, domain: 'other.example' }, + }); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), + ); + 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'); + }); + + 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 __experimental_getPoppyJson = vi.fn().mockRejectedValue(failure); + vi.mocked(clerkClient).mockResolvedValue( + Object.assign(await clerkClient(), { artifacts: { __experimental_getPoppyJson } }), + ); + const middleware = clerkMiddleware({ __experimental_poppy: true }); + await expect( + middleware(mockRequest({ url: 'https://company.example' + path }), {} as NextFetchEvent), + ).rejects.toBe(failure); + expect(authenticateRequestMock).not.toHaveBeenCalled(); + }, + ); + + 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: agentId, poppy: { session_id: 'protocol-session' } }, + 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, + toAuth: () => ({ + tokenType: TokenType.SessionToken, + isAuthenticated: signedIn, + userId: signedIn ? 'user_123' : null, + debug: vi.fn(), + }), + headers: new Headers(), + publishableKey, + }); + const middleware = clerkMiddleware({ __experimental_poppy: true }); + const response = await middleware(mockRequest({ url: 'https://company.example/cart' }), {} as NextFetchEvent); + assert(response instanceof Response); + 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); + if (signedIn) { + expect(appAuth.agent).toEqual({ + type: 'agent', + sub: agentId, + }); + expectTypeOf(appAuth.agent?.sub).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..06b5416644f 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -206,6 +206,36 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl return clerkMiddlewareRequestDataStorage.run(requestDataStore, async () => { const resolvedClerkClient = await clerkClient(); + if (options.__experimental_poppy) { + const poppyRequestUrl = createClerkRequest(request).clerkUrl; + 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.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.'); + } + 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' } }, + ); + } + } + if (options.debug) { logger.enable(); }