a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(oauth-browser-client): add client assertions

Mary 80b400e2 e30afd6d

+140 -67
+36
.changeset/swift-owls-sin.md
··· 1 + --- 2 + '@atcute/oauth-browser-client': minor 3 + --- 4 + 5 + add support for client assertions. 6 + 7 + this adds an optional `fetchClientAssertion` callback to `configureOAuth` that lets you fetch client 8 + assertions from your backend, allowing your client to be classified as a confidential client. 9 + 10 + ```ts 11 + import { configureOAuth } from '@atcute/oauth-browser-client'; 12 + 13 + configureOAuth({ 14 + // ... existing config 15 + 16 + async fetchClientAssertion({ jkt, aud, createDpopProof }) { 17 + const dpop = await createDpopProof('https://example.com/api/client-assertion'); 18 + 19 + const response = await fetch('https://example.com/api/client-assertion', { 20 + method: 'POST', 21 + headers: { 22 + dpop: dpop, 23 + 'content-type': 'application/json', 24 + }, 25 + body: JSON.stringify({ jkt, aud }), 26 + }); 27 + 28 + const data = await response.json(); 29 + 30 + return { 31 + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 32 + client_assertion: data.assertion, 33 + }; 34 + }, 35 + }); 36 + ```
+38 -61
packages/oauth/browser-client/README.md
··· 174 174 175 175 ## confidential client mode (optional) 176 176 177 - by default, `@atcute/oauth-browser-client` operates as a **public client**, which means it cannot 178 - securely store credentials. this results in shorter session lifetimes enforced by authorization 179 - servers. 177 + by default, `@atcute/oauth-browser-client` operates as a **public client**, resulting in shorter 178 + session lifetimes by authorization servers as it's deemed to be unable to securely store 179 + credentials. 180 180 181 181 if you want longer-lived sessions and better security controls, you can enable **confidential client 182 - mode** by setting up a client assertion backend service. 182 + mode** by setting up a [client assertion backend](client-assertion-backend). 183 183 184 - ### how it works 185 - 186 - the 187 - [client assertion backend pattern](https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend) 188 - allows browser apps to act as confidential clients: 189 - 190 - 1. your browser app generates a DPoP key (this already happens automatically) 191 - 2. when requesting tokens, the browser sends a DPoP proof to your backend service 192 - 3. your backend validates the proof and returns a signed client assertion (JWT) that's 193 - cryptographically bound to the DPoP key via the `cnf` (confirmation) claim 194 - 4. the browser includes both the client assertion and DPoP proof in token requests 195 - 5. the authorization server verifies the binding and issues longer-lived tokens 184 + [client-assertion-backend]: 185 + https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend 196 186 197 187 ### setup 198 188 ··· 202 192 import { configureOAuth } from '@atcute/oauth-browser-client'; 203 193 204 194 configureOAuth({ 205 - metadata: { 206 - client_id: 'https://example.com/oauth-client-metadata.json', 207 - redirect_uri: 'https://example.com/oauth/callback', 208 - }, 209 - // enable confidential client mode with your custom backend: 210 - fetchClientAssertion: async ({ jkt, createDpopProof, aud }) => { 211 - // Create DPoP proof for authenticating to your backend 195 + // ... existing config 196 + 197 + async fetchClientAssertion({ jkt, aud, createDpopProof }) { 212 198 const dpop = await createDpopProof('https://example.com/api/client-assertion'); 213 199 214 - // Call your backend endpoint (design your own API format) 215 200 const response = await fetch('https://example.com/api/client-assertion', { 216 201 method: 'POST', 217 - headers: { dpop: dpop }, 202 + headers: { 203 + dpop: dpop, 204 + 'content-type': 'application/json', 205 + }, 218 206 body: JSON.stringify({ jkt, aud }), 219 207 }); 220 208 221 209 const data = await response.json(); 210 + 222 211 return { 212 + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 223 213 client_assertion: data.assertion, 224 - client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 225 214 }; 226 215 }, 227 216 }); 228 217 ``` 229 218 230 - the backend API format is up to you - there's no standardized spec. design it however works best for 231 - your infrastructure (authentication, request format, error handling, etc.). 219 + the backend API is completely up to you—there's no standardized spec. design it however works best 220 + for your infrastructure (authentication, request format, error handling, etc.) 232 221 233 - the library will automatically: 222 + your backend needs to validate the incoming DPoP proof and sign a client assertion JWT with the 223 + following interface: 234 224 235 - - calculate JWK thumbprints for DPoP keys 236 - - provide a `createDpopProof()` function for backend authentication 237 - - request client assertions when making token requests 238 - 239 - **important**: if you configure `fetchClientAssertion`, your backend **must** be available. there is 240 - no fallback to public client mode, because your OAuth client metadata will declare you as a 241 - confidential client, and authorization servers will reject requests without client assertions. 225 + ```ts 226 + interface ClientAssertionJwt { 227 + /** your client ID */ 228 + iss: string; 229 + /** also your client ID */ 230 + sub: string; 231 + /** the authorization server receiving this token */ 232 + aud: string; 233 + /** when this token expires */ 234 + exp: number; 235 + /** unique nonce */ 236 + jti: string; 237 + /** asserts that this jkt is allowed */ 238 + cnf: { jkt: string }; 239 + } 240 + ``` 242 241 243 - ### backend requirements 244 - 245 - your backend service needs to: 246 - 247 - 1. accept POST requests with DPoP proofs in the `DPoP` header 248 - 2. validate the incoming DPoP proof 249 - 3. generate and sign a client assertion JWT with: 250 - - standard claims: `iss`, `sub` (both should be your `client_id`), `aud` (authorization server 251 - issuer), `exp`, `jti` 252 - - **crucial**: include `cnf: { jkt }` claim with the JWK thumbprint of the DPoP key 253 - 4. return `{ "client_assertion": "<signed-jwt>" }` 254 - 255 - additionally: 256 - 257 - - enforce CORS to only allow requests from your frontend origin 258 - - never cache responses (client assertions should be fresh) 259 - - optionally track devices via DPoP keys and refuse assertions for suspicious sessions 242 + you're able to use the `jkt` to refuse assertions when necessary (suspicious activity, compromised 243 + code, etc.) 260 244 261 245 ### client metadata updates 262 246 ··· 273 257 } 274 258 ``` 275 259 276 - the `jwks_uri` should expose the public keys used to sign client assertions (not the DPoP keys!). 277 - 278 - ### benefits 279 - 280 - - **longer sessions**: authorization servers grant extended refresh token lifetimes to confidential 281 - clients 282 - - **better security**: your backend can revoke sessions, track devices, and enforce policies 283 - - **mass revocation**: rotate your backend keypair to instantly invalidate all sessions 260 + the `jwks_uri` should expose the public keys used to sign client assertions. 284 261 285 262 ## additional guide 286 263
+22 -2
packages/oauth/browser-client/lib/agents/server-agent.ts
··· 1 1 import type { Did } from '@atcute/lexicons'; 2 2 3 - import { createDPoPFetch } from '../dpop.js'; 4 - import { CLIENT_ID, REDIRECT_URI } from '../environment.js'; 3 + import { createDPoPFetch, createDPoPSignage } from '../dpop.js'; 4 + import { CLIENT_ID, fetchClientAssertion, REDIRECT_URI } from '../environment.js'; 5 5 import { FetchResponseError, OAuthResponseError, TokenRefreshError } from '../errors.js'; 6 6 import { resolveFromIdentifier } from '../resolvers.js'; 7 7 import type { DPoPKey } from '../types/dpop.js'; ··· 14 14 export class OAuthServerAgent { 15 15 #fetch: typeof fetch; 16 16 #metadata: PersistedAuthorizationServerMetadata; 17 + #dpopKey: DPoPKey; 17 18 18 19 constructor(metadata: PersistedAuthorizationServerMetadata, dpopKey: DPoPKey) { 19 20 this.#metadata = metadata; 21 + this.#dpopKey = dpopKey; 20 22 this.#fetch = createDPoPFetch(dpopKey, true); 21 23 } 22 24 ··· 31 33 const url: string | undefined = (this.#metadata as any)[`${endpoint}_endpoint`]; 32 34 if (!url) { 33 35 throw new Error(`no endpoint for ${endpoint}`); 36 + } 37 + 38 + if (endpoint === 'token' && fetchClientAssertion !== undefined) { 39 + const jkt = this.#dpopKey.jkt; 40 + if (jkt === undefined) { 41 + throw new Error(`DPoP key missing jkt field`); 42 + } 43 + 44 + const clientAssertionCredentials = await fetchClientAssertion({ 45 + jkt: jkt, 46 + aud: this.#metadata.issuer, 47 + createDpopProof: async (url) => { 48 + const sign = createDPoPSignage(this.#dpopKey); 49 + return await sign('POST', url, undefined, undefined); 50 + }, 51 + }); 52 + 53 + payload = { ...payload, ...clientAssertionCredentials }; 34 54 } 35 55 36 56 const response = await this.#fetch(url, {
+4
packages/oauth/browser-client/lib/dpop.ts
··· 16 16 const key = await crypto.subtle.exportKey('pkcs8', pair.privateKey); 17 17 const { ext: _ext, key_ops: _key_opts, ...jwk } = await crypto.subtle.exportKey('jwk', pair.publicKey); 18 18 19 + const canonicalJwk = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); 20 + const jkt = await stringToSha256(canonicalJwk); 21 + 19 22 return { 20 23 typ: 'ES256', 21 24 key: toBase64Url(new Uint8Array(key)), 22 25 jwt: toBase64Url(encodeUtf8(JSON.stringify({ typ: 'dpop+jwt', alg: 'ES256', jwk: jwk }))), 26 + jkt: jkt, 23 27 }; 24 28 }; 25 29
+12 -4
packages/oauth/browser-client/lib/environment.ts
··· 1 1 import type { IdentityResolver } from './types/identity.js'; 2 2 3 3 import { createOAuthDatabase, type OAuthDatabase } from './store/db.js'; 4 + import type { ClientAssertionFetcher } from './types/client-assertion.js'; 4 5 5 6 export let CLIENT_ID: string; 6 7 export let REDIRECT_URI: string; 8 + 9 + export let fetchClientAssertion: ClientAssertionFetcher | undefined; 7 10 8 11 export let database: OAuthDatabase; 9 12 10 13 export let identityResolver: IdentityResolver; 11 14 12 15 export interface ConfigureOAuthOptions { 13 - /** resolves actor identifiers into identity metadata */ 14 - identityResolver: IdentityResolver; 15 - 16 16 /** 17 17 * client metadata, necessary to drive the whole request 18 18 */ ··· 21 21 redirect_uri: string; 22 22 }; 23 23 24 + /** resolves actor identifiers into identity metadata */ 25 + identityResolver: IdentityResolver; 26 + 27 + /** 28 + * optional function to fetch DPoP-bound client assertions from your backend. 29 + */ 30 + fetchClientAssertion?: ClientAssertionFetcher; 31 + 24 32 /** 25 33 * name that will be used as prefix for storage keys needed to persist authentication. 26 34 * @default "atcute-oauth" ··· 29 37 } 30 38 31 39 export const configureOAuth = (options: ConfigureOAuthOptions) => { 32 - ({ identityResolver } = options); 40 + ({ identityResolver, fetchClientAssertion } = options); 33 41 ({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI } = options.metadata); 34 42 35 43 database = createOAuthDatabase({ name: options.storageName ?? 'atcute-oauth' });
+1
packages/oauth/browser-client/lib/index.ts
··· 7 7 export * from './agents/sessions.js'; 8 8 export * from './agents/user-agent.js'; 9 9 10 + export * from './types/client-assertion.js'; 10 11 export * from './types/client.js'; 11 12 export * from './types/dpop.js'; 12 13 export * from './types/identity.js';
+25
packages/oauth/browser-client/lib/types/client-assertion.ts
··· 1 + const CLIENT_ASSERTION_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; 2 + 3 + export interface ClientAssertionCredentials { 4 + client_assertion: string; 5 + client_assertion_type: typeof CLIENT_ASSERTION_TYPE_JWT_BEARER; 6 + } 7 + 8 + export interface FetchClientAssertionParams { 9 + /** JWK thumbprint of the DPoP key to bind the assertion to */ 10 + jkt: string; 11 + /** authorization server issuer (audience for the assertion) */ 12 + aud: string; 13 + 14 + /** 15 + * create a DPoP proof to prove you possess the key for the claimed jkt. 16 + * 17 + * @param htu origin and pathname to your backend 18 + * @returns DPoP proof that can be included in the assertion 19 + */ 20 + createDpopProof: (htu: string) => Promise<string>; 21 + } 22 + 23 + export type ClientAssertionFetcher = ( 24 + params: FetchClientAssertionParams, 25 + ) => Promise<ClientAssertionCredentials>;
+2
packages/oauth/browser-client/lib/types/dpop.ts
··· 4 4 key: string; 5 5 /** base64url-encoded jwt token */ 6 6 jwt: string; 7 + /** JWK thumbprint (RFC 7638) for this key, used for client assertion binding */ 8 + jkt: string | undefined; 7 9 }