···11+---
22+'@atcute/oauth-browser-client': minor
33+---
44+55+add support for client assertions.
66+77+this adds an optional `fetchClientAssertion` callback to `configureOAuth` that lets you fetch client
88+assertions from your backend, allowing your client to be classified as a confidential client.
99+1010+```ts
1111+import { configureOAuth } from '@atcute/oauth-browser-client';
1212+1313+configureOAuth({
1414+ // ... existing config
1515+1616+ async fetchClientAssertion({ jkt, aud, createDpopProof }) {
1717+ const dpop = await createDpopProof('https://example.com/api/client-assertion');
1818+1919+ const response = await fetch('https://example.com/api/client-assertion', {
2020+ method: 'POST',
2121+ headers: {
2222+ dpop: dpop,
2323+ 'content-type': 'application/json',
2424+ },
2525+ body: JSON.stringify({ jkt, aud }),
2626+ });
2727+2828+ const data = await response.json();
2929+3030+ return {
3131+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
3232+ client_assertion: data.assertion,
3333+ };
3434+ },
3535+});
3636+```
+38-61
packages/oauth/browser-client/README.md
···174174175175## confidential client mode (optional)
176176177177-by default, `@atcute/oauth-browser-client` operates as a **public client**, which means it cannot
178178-securely store credentials. this results in shorter session lifetimes enforced by authorization
179179-servers.
177177+by default, `@atcute/oauth-browser-client` operates as a **public client**, resulting in shorter
178178+session lifetimes by authorization servers as it's deemed to be unable to securely store
179179+credentials.
180180181181if you want longer-lived sessions and better security controls, you can enable **confidential client
182182-mode** by setting up a client assertion backend service.
182182+mode** by setting up a [client assertion backend](client-assertion-backend).
183183184184-### how it works
185185-186186-the
187187-[client assertion backend pattern](https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend)
188188-allows browser apps to act as confidential clients:
189189-190190-1. your browser app generates a DPoP key (this already happens automatically)
191191-2. when requesting tokens, the browser sends a DPoP proof to your backend service
192192-3. your backend validates the proof and returns a signed client assertion (JWT) that's
193193- cryptographically bound to the DPoP key via the `cnf` (confirmation) claim
194194-4. the browser includes both the client assertion and DPoP proof in token requests
195195-5. the authorization server verifies the binding and issues longer-lived tokens
184184+[client-assertion-backend]:
185185+ https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend
196186197187### setup
198188···202192import { configureOAuth } from '@atcute/oauth-browser-client';
203193204194configureOAuth({
205205- metadata: {
206206- client_id: 'https://example.com/oauth-client-metadata.json',
207207- redirect_uri: 'https://example.com/oauth/callback',
208208- },
209209- // enable confidential client mode with your custom backend:
210210- fetchClientAssertion: async ({ jkt, createDpopProof, aud }) => {
211211- // Create DPoP proof for authenticating to your backend
195195+ // ... existing config
196196+197197+ async fetchClientAssertion({ jkt, aud, createDpopProof }) {
212198 const dpop = await createDpopProof('https://example.com/api/client-assertion');
213199214214- // Call your backend endpoint (design your own API format)
215200 const response = await fetch('https://example.com/api/client-assertion', {
216201 method: 'POST',
217217- headers: { dpop: dpop },
202202+ headers: {
203203+ dpop: dpop,
204204+ 'content-type': 'application/json',
205205+ },
218206 body: JSON.stringify({ jkt, aud }),
219207 });
220208221209 const data = await response.json();
210210+222211 return {
212212+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
223213 client_assertion: data.assertion,
224224- client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
225214 };
226215 },
227216});
228217```
229218230230-the backend API format is up to you - there's no standardized spec. design it however works best for
231231-your infrastructure (authentication, request format, error handling, etc.).
219219+the backend API is completely up to you—there's no standardized spec. design it however works best
220220+for your infrastructure (authentication, request format, error handling, etc.)
232221233233-the library will automatically:
222222+your backend needs to validate the incoming DPoP proof and sign a client assertion JWT with the
223223+following interface:
234224235235-- calculate JWK thumbprints for DPoP keys
236236-- provide a `createDpopProof()` function for backend authentication
237237-- request client assertions when making token requests
238238-239239-**important**: if you configure `fetchClientAssertion`, your backend **must** be available. there is
240240-no fallback to public client mode, because your OAuth client metadata will declare you as a
241241-confidential client, and authorization servers will reject requests without client assertions.
225225+```ts
226226+interface ClientAssertionJwt {
227227+ /** your client ID */
228228+ iss: string;
229229+ /** also your client ID */
230230+ sub: string;
231231+ /** the authorization server receiving this token */
232232+ aud: string;
233233+ /** when this token expires */
234234+ exp: number;
235235+ /** unique nonce */
236236+ jti: string;
237237+ /** asserts that this jkt is allowed */
238238+ cnf: { jkt: string };
239239+}
240240+```
242241243243-### backend requirements
244244-245245-your backend service needs to:
246246-247247-1. accept POST requests with DPoP proofs in the `DPoP` header
248248-2. validate the incoming DPoP proof
249249-3. generate and sign a client assertion JWT with:
250250- - standard claims: `iss`, `sub` (both should be your `client_id`), `aud` (authorization server
251251- issuer), `exp`, `jti`
252252- - **crucial**: include `cnf: { jkt }` claim with the JWK thumbprint of the DPoP key
253253-4. return `{ "client_assertion": "<signed-jwt>" }`
254254-255255-additionally:
256256-257257-- enforce CORS to only allow requests from your frontend origin
258258-- never cache responses (client assertions should be fresh)
259259-- optionally track devices via DPoP keys and refuse assertions for suspicious sessions
242242+you're able to use the `jkt` to refuse assertions when necessary (suspicious activity, compromised
243243+code, etc.)
260244261245### client metadata updates
262246···273257}
274258```
275259276276-the `jwks_uri` should expose the public keys used to sign client assertions (not the DPoP keys!).
277277-278278-### benefits
279279-280280-- **longer sessions**: authorization servers grant extended refresh token lifetimes to confidential
281281- clients
282282-- **better security**: your backend can revoke sessions, track devices, and enforce policies
283283-- **mass revocation**: rotate your backend keypair to instantly invalidate all sessions
260260+the `jwks_uri` should expose the public keys used to sign client assertions.
284261285262## additional guide
286263
···11import type { IdentityResolver } from './types/identity.js';
2233import { createOAuthDatabase, type OAuthDatabase } from './store/db.js';
44+import type { ClientAssertionFetcher } from './types/client-assertion.js';
4556export let CLIENT_ID: string;
67export let REDIRECT_URI: string;
88+99+export let fetchClientAssertion: ClientAssertionFetcher | undefined;
710811export let database: OAuthDatabase;
9121013export let identityResolver: IdentityResolver;
11141215export interface ConfigureOAuthOptions {
1313- /** resolves actor identifiers into identity metadata */
1414- identityResolver: IdentityResolver;
1515-1616 /**
1717 * client metadata, necessary to drive the whole request
1818 */
···2121 redirect_uri: string;
2222 };
23232424+ /** resolves actor identifiers into identity metadata */
2525+ identityResolver: IdentityResolver;
2626+2727+ /**
2828+ * optional function to fetch DPoP-bound client assertions from your backend.
2929+ */
3030+ fetchClientAssertion?: ClientAssertionFetcher;
3131+2432 /**
2533 * name that will be used as prefix for storage keys needed to persist authentication.
2634 * @default "atcute-oauth"
···2937}
30383139export const configureOAuth = (options: ConfigureOAuthOptions) => {
3232- ({ identityResolver } = options);
4040+ ({ identityResolver, fetchClientAssertion } = options);
3341 ({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI } = options.metadata);
34423543 database = createOAuthDatabase({ name: options.storageName ?? 'atcute-oauth' });
+1
packages/oauth/browser-client/lib/index.ts
···77export * from './agents/sessions.js';
88export * from './agents/user-agent.js';
991010+export * from './types/client-assertion.js';
1011export * from './types/client.js';
1112export * from './types/dpop.js';
1213export * from './types/identity.js';
···11+const CLIENT_ASSERTION_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
22+33+export interface ClientAssertionCredentials {
44+ client_assertion: string;
55+ client_assertion_type: typeof CLIENT_ASSERTION_TYPE_JWT_BEARER;
66+}
77+88+export interface FetchClientAssertionParams {
99+ /** JWK thumbprint of the DPoP key to bind the assertion to */
1010+ jkt: string;
1111+ /** authorization server issuer (audience for the assertion) */
1212+ aud: string;
1313+1414+ /**
1515+ * create a DPoP proof to prove you possess the key for the claimed jkt.
1616+ *
1717+ * @param htu origin and pathname to your backend
1818+ * @returns DPoP proof that can be included in the assertion
1919+ */
2020+ createDpopProof: (htu: string) => Promise<string>;
2121+}
2222+2323+export type ClientAssertionFetcher = (
2424+ params: FetchClientAssertionParams,
2525+) => Promise<ClientAssertionCredentials>;
+2
packages/oauth/browser-client/lib/types/dpop.ts
···44 key: string;
55 /** base64url-encoded jwt token */
66 jwt: string;
77+ /** JWK thumbprint (RFC 7638) for this key, used for client assertion binding */
88+ jkt: string | undefined;
79}