Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/poppy-browser-session-v04.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 50 additions & 0 deletions packages/backend/src/api/__tests__/ArtifactsApi.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
15 changes: 15 additions & 0 deletions packages/backend/src/api/endpoints/ArtifactsApi.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
WaitlistEntryAPI,
WebhookAPI,
} from './endpoints';
import { ArtifactsAPI } from './endpoints/ArtifactsApi';
import { BillingAPI } from './endpoints/BillingApi';
import { buildRequest } from './request';

Expand Down Expand Up @@ -63,6 +64,7 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
skipApiVersionInUrl: true,
}),
),
artifacts: new ArtifactsAPI(request),
betaFeatures: new BetaFeaturesAPI(request),
blocklistIdentifiers: new BlocklistIdentifierAPI(request),
/**
Expand Down
72 changes: 70 additions & 2 deletions packages/backend/src/tokens/__tests__/authObjects.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<AgentActClaim | undefined>();
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', {
Expand Down
119 changes: 119 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
mockInvalidSignatureJwt,
mockJwks,
mockJwt,
mockJwtHeader,
mockJwtPayload,
mockM2MJwtPayload,
pemEncodedPublicKey,
signingJwks,
} from '../../fixtures';
import {
Expand All @@ -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';
Expand Down Expand Up @@ -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
//
Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createCheckAuthorization } from '@clerk/shared/authorization';
import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser';
import type {
AgentActClaim,
CheckAuthorizationFromSessionClaims,
Jwt,
JwtPayload,
Expand Down Expand Up @@ -31,6 +32,18 @@ type AuthObjectDebug = () => AuthObjectDebugData;

type Claims = Record<string, any>;

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
*/
Expand All @@ -42,6 +55,7 @@ export type SignedInAuthObjectOptions = CreateBackendApiOptions & {
* @internal
*/
export type SignedInAuthObject = SharedSignedInAuthObjectProperties & {
agent?: AgentActClaim;
/**
* The allowed token type.
*/
Expand All @@ -65,6 +79,7 @@ export type SignedInAuthObject = SharedSignedInAuthObjectProperties & {
* @internal
*/
export type SignedOutAuthObject = {
agent?: undefined;
sessionClaims: null;
sessionId: null;
sessionStatus: SessionStatusClaim | null;
Expand Down Expand Up @@ -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,
Expand All @@ -206,6 +222,7 @@ export function signedInAuthObject(
});
return {
tokenType: TokenType.SessionToken,
...(agent ? { agent } : {}),
actor,
sessionClaims,
sessionId,
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/tokens/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading
Loading