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-cab): initial commit

Mary 9ab26de2 57d7a62c

+1077
+77
packages/oauth/cab/README.md
··· 1 + # @atcute/oauth-cab 2 + 3 + Client Assertion Backend (CAB) for AT Protocol OAuth browser clients. 4 + 5 + ```sh 6 + npm install @atcute/oauth-cab 7 + ``` 8 + 9 + CAB enables browser-based OAuth clients to become confidential clients by having a backend service 10 + that issues DPoP-bound client assertions. 11 + 12 + ## usage 13 + 14 + ### server-side (CAB backend) 15 + 16 + > **note:** the CAB endpoint should only accept requests from your client's origin(s) to prevent 17 + > other websites from abusing it. serving the endpoint from the same origin as your web application 18 + > is the simplest way to enforce this. 19 + 20 + #### standalone handler 21 + 22 + ```ts 23 + import { createCabHandler, Keyset, generatePrivateKey } from '@atcute/oauth-cab/server'; 24 + 25 + // create keyset 26 + const keyset = new Keyset([await generatePrivateKey('my-key')]); 27 + 28 + // create handler (returns undefined for non-matching paths) 29 + const handler = await createCabHandler({ 30 + clientId: 'https://example.com/client-metadata.json', 31 + keyset, 32 + // dpopSecret: false, // disable DPoP nonce requirement 33 + // dpopSecret: 'hex-secret', // shared secret for multi-instance deployments 34 + }); 35 + 36 + // use with Hono 37 + app.all('*', async (c, next) => { 38 + const res = await handler(c.req.raw); 39 + if (res === undefined) { 40 + return next(); 41 + } 42 + return res; 43 + }); 44 + ``` 45 + 46 + #### with XRPC router 47 + 48 + ```ts 49 + import { registerCab, Keyset, generatePrivateKey } from '@atcute/oauth-cab/server'; 50 + import { XRPCRouter, cors } from '@atcute/xrpc-server'; 51 + 52 + const keyset = new Keyset([await generatePrivateKey('my-key')]); 53 + 54 + const router = new XRPCRouter({ 55 + // if using CORS middleware, exclude CAB endpoint (it should be same-origin) 56 + middlewares: [cors({ exclude: ['dev.atcute.oauth.getClientAssertion'] })], 57 + }); 58 + 59 + await registerCab(router, { 60 + clientId: 'https://example.com/client-metadata.json', 61 + keyset, 62 + }); 63 + 64 + export default router; 65 + ``` 66 + 67 + ### client-side (browser) 68 + 69 + ```ts 70 + import { createCABFetcher } from '@atcute/oauth-cab/client'; 71 + import { configureOAuth } from '@atcute/oauth-browser-client'; 72 + 73 + configureOAuth({ 74 + // ... other options 75 + fetchClientAssertion: createCABFetcher(), // defaults to location.origin 76 + }); 77 + ```
+6
packages/oauth/cab/lex.config.js
··· 1 + import { defineLexiconConfig } from '@atcute/lex-cli'; 2 + 3 + export default defineLexiconConfig({ 4 + files: ['lexicons-src/**/*.ts'], 5 + outdir: 'lib/lexicons/', 6 + });
+28
packages/oauth/cab/lexicons-src/dev/atcute/oauth/get-client-assertion.ts
··· 1 + import { document, object, procedure, required, string } from '@atcute/lexicon-doc/builder'; 2 + 3 + const input = object({ 4 + properties: { 5 + aud: required(string({ description: 'authorization server issuer' })), 6 + }, 7 + }); 8 + 9 + const output = object({ 10 + properties: { 11 + client_assertion: required(string({ description: 'signed JWT assertion' })), 12 + }, 13 + }); 14 + 15 + export default document({ 16 + id: 'dev.atcute.oauth.getClientAssertion', 17 + defs: { 18 + main: procedure({ 19 + description: 'get a DPoP-bound client assertion for OAuth token requests', 20 + input: { encoding: 'application/json', schema: input }, 21 + output: { encoding: 'application/json', schema: output }, 22 + errors: [ 23 + { name: 'InvalidDpopProof', description: 'DPoP proof is missing or invalid' }, 24 + { name: 'UseDpopNonce', description: 'retry with the provided DPoP-Nonce' }, 25 + ], 26 + }), 27 + }, 28 + });
+86
packages/oauth/cab/lib/client/index.ts
··· 1 + import { Client, simpleFetchHandler } from '@atcute/client'; 2 + 3 + // import lexicon types to augment XRPCProcedures 4 + import '../lexicons/index.js'; 5 + 6 + import type { 7 + ClientAssertionCredentials, 8 + ClientAssertionFetcher, 9 + FetchClientAssertionParams, 10 + } from './types.js'; 11 + 12 + const CLIENT_ASSERTION_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; 13 + 14 + /** 15 + * options for creating a CAB fetcher 16 + */ 17 + export interface CreateCABFetcherOptions { 18 + /** URL of CAB backend (defaults to location.origin) */ 19 + service?: string | URL; 20 + /** optional custom fetch implementation */ 21 + fetch?: typeof globalThis.fetch; 22 + } 23 + 24 + /** 25 + * creates a client assertion fetcher that communicates with a CAB backend. 26 + * 27 + * the fetcher handles DPoP proof creation and nonce retry automatically. 28 + * 29 + * @param options fetcher configuration 30 + * @returns client assertion fetcher for use with oauth-browser-client 31 + */ 32 + export const createCABFetcher = (options: CreateCABFetcherOptions = {}): ClientAssertionFetcher => { 33 + const serviceUrl = new URL(options.service ?? location.origin); 34 + 35 + const client = new Client({ 36 + handler: simpleFetchHandler({ 37 + service: serviceUrl, 38 + fetch: options.fetch, 39 + }), 40 + }); 41 + 42 + return async (params: FetchClientAssertionParams): Promise<ClientAssertionCredentials> => { 43 + const { aud, createDpopProof } = params; 44 + 45 + // build the endpoint URL for DPoP proof (htu is origin + pathname only) 46 + const htu = serviceUrl.origin + '/xrpc/dev.atcute.oauth.getClientAssertion'; 47 + 48 + // create initial DPoP proof without nonce 49 + let dpopProof = await createDpopProof(htu); 50 + 51 + // make the request 52 + let response = await client.post('dev.atcute.oauth.getClientAssertion', { 53 + input: { aud }, 54 + headers: { DPoP: dpopProof }, 55 + }); 56 + 57 + // check for nonce requirement (UseDpopNonce error) 58 + if (!response.ok && response.data.error === 'UseDpopNonce') { 59 + const dpopNonce = response.headers.get('DPoP-Nonce'); 60 + if (dpopNonce) { 61 + // retry with nonce 62 + dpopProof = await createDpopProof(htu, dpopNonce); 63 + 64 + response = await client.post('dev.atcute.oauth.getClientAssertion', { 65 + input: { aud }, 66 + headers: { DPoP: dpopProof }, 67 + }); 68 + } 69 + } 70 + 71 + if (!response.ok) { 72 + const message = response.data.message ?? response.data.error ?? 'CAB request failed'; 73 + throw new Error(message); 74 + } 75 + 76 + const { client_assertion } = response.data; 77 + 78 + return { 79 + client_assertion, 80 + client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER, 81 + }; 82 + }; 83 + }; 84 + 85 + // re-export types for convenience 86 + export type { ClientAssertionCredentials, ClientAssertionFetcher, FetchClientAssertionParams };
+35
packages/oauth/cab/lib/client/types.ts
··· 1 + /** 2 + * client assertion credentials returned from a CAB backend. 3 + */ 4 + export interface ClientAssertionCredentials { 5 + /** the signed JWT assertion */ 6 + client_assertion: string; 7 + /** the assertion type (always jwt-bearer) */ 8 + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; 9 + } 10 + 11 + /** 12 + * parameters for fetching a client assertion. 13 + */ 14 + export interface FetchClientAssertionParams { 15 + /** JWK thumbprint of the DPoP key to bind the assertion to */ 16 + jkt: string; 17 + /** authorization server issuer (audience for the assertion) */ 18 + aud: string; 19 + 20 + /** 21 + * create a DPoP proof to prove you possess the key for the claimed jkt. 22 + * 23 + * @param htu origin and pathname to the CAB backend 24 + * @param nonce optional DPoP nonce from the server 25 + * @returns DPoP proof that can be included in the request 26 + */ 27 + createDpopProof: (htu: string, nonce?: string) => Promise<string>; 28 + } 29 + 30 + /** 31 + * function that fetches a client assertion from a CAB backend. 32 + */ 33 + export type ClientAssertionFetcher = ( 34 + params: FetchClientAssertionParams, 35 + ) => Promise<ClientAssertionCredentials>;
+1
packages/oauth/cab/lib/lexicons/index.ts
··· 1 + export * as DevAtcuteOauthGetClientAssertion from './types/dev/atcute/oauth/getClientAssertion.js';
+41
packages/oauth/cab/lib/lexicons/types/dev/atcute/oauth/getClientAssertion.ts
··· 1 + import type {} from '@atcute/lexicons'; 2 + import * as v from '@atcute/lexicons/validations'; 3 + import type {} from '@atcute/lexicons/ambient'; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.procedure('dev.atcute.oauth.getClientAssertion', { 6 + params: null, 7 + input: { 8 + type: 'lex', 9 + schema: /*#__PURE__*/ v.object({ 10 + /** 11 + * authorization server issuer 12 + */ 13 + aud: /*#__PURE__*/ v.string(), 14 + }), 15 + }, 16 + output: { 17 + type: 'lex', 18 + schema: /*#__PURE__*/ v.object({ 19 + /** 20 + * signed JWT assertion 21 + */ 22 + client_assertion: /*#__PURE__*/ v.string(), 23 + }), 24 + }, 25 + }); 26 + 27 + type main$schematype = typeof _mainSchema; 28 + 29 + export interface mainSchema extends main$schematype {} 30 + 31 + export const mainSchema = _mainSchema as mainSchema; 32 + 33 + export interface $params {} 34 + export interface $input extends v.InferXRPCBodyInput<mainSchema['input']> {} 35 + export interface $output extends v.InferXRPCBodyInput<mainSchema['output']> {} 36 + 37 + declare module '@atcute/lexicons/ambient' { 38 + interface XRPCProcedures { 39 + 'dev.atcute.oauth.getClientAssertion': mainSchema; 40 + } 41 + }
+66
packages/oauth/cab/lib/server/client-assertion.ts
··· 1 + import { SignJWT } from 'jose'; 2 + import { nanoid } from 'nanoid'; 3 + 4 + import type { Keyset } from '@atcute/oauth-keyset'; 5 + 6 + /** 7 + * options for creating a client assertion 8 + */ 9 + export interface CreateClientAssertionOptions { 10 + /** client ID (used as iss and sub) */ 11 + clientId: string; 12 + /** authorization server issuer (used as aud) */ 13 + audience: string; 14 + /** JWK thumbprint of the DPoP key to bind to (cnf.jkt) */ 15 + jkt: string; 16 + /** client's private keyset */ 17 + keyset: Keyset; 18 + /** optional algorithms supported by the server */ 19 + serverAlgs?: readonly string[]; 20 + } 21 + 22 + /** 23 + * result of client assertion creation 24 + */ 25 + export interface ClientAssertionResult { 26 + /** the signed JWT assertion */ 27 + client_assertion: string; 28 + } 29 + 30 + /** 31 + * creates a DPoP-bound client assertion per RFC 7523. 32 + * 33 + * the assertion includes a `cnf.jkt` claim binding it to the provided DPoP key thumbprint. 34 + * 35 + * @param options creation options 36 + * @returns client assertion credentials 37 + */ 38 + export const createClientAssertion = async ( 39 + options: CreateClientAssertionOptions, 40 + ): Promise<ClientAssertionResult> => { 41 + const { clientId, audience, jkt, keyset, serverAlgs } = options; 42 + 43 + // find a compatible key 44 + const { key, alg } = keyset.findForSigning(serverAlgs); 45 + 46 + const now = Math.floor(Date.now() / 1000); 47 + 48 + const assertion = await new SignJWT({ 49 + // RFC 7523 claims 50 + iss: clientId, 51 + sub: clientId, 52 + aud: audience, 53 + jti: nanoid(24), 54 + iat: now, 55 + exp: now + 60, // 1 minute 56 + 57 + // DPoP binding (RFC 9449) 58 + cnf: { jkt }, 59 + }) 60 + .setProtectedHeader({ alg, kid: key.kid }) 61 + .sign(key.key); 62 + 63 + return { 64 + client_assertion: assertion, 65 + }; 66 + };
+162
packages/oauth/cab/lib/server/dpop-nonce.ts
··· 1 + import { toBase64Url } from '@atcute/multibase'; 2 + import { randomBytes } from '@atcute/uint8array'; 3 + 4 + /** max age for DPoP nonces (3 minutes) */ 5 + const DPOP_NONCE_MAX_AGE = 3 * 60 * 1000; 6 + 7 + /** rotation interval (1 minute) */ 8 + const ROTATION_INTERVAL = DPOP_NONCE_MAX_AGE / 3; 9 + 10 + /** secret byte length */ 11 + const SECRET_BYTE_LENGTH = 32; 12 + 13 + export type DpopSecret = string | Uint8Array<ArrayBuffer>; 14 + 15 + /** 16 + * HMAC-based DPoP nonce manager. 17 + * 18 + * generates deterministic nonces based on time and a secret, allowing 19 + * validation without storing individual nonces. maintains a window of 20 + * 3 valid nonces (prev, now, next) for clock skew tolerance. 21 + */ 22 + export class DpopNonce { 23 + readonly #key: CryptoKey; 24 + readonly #rotationInterval: number; 25 + 26 + #counter: number; 27 + #prev: string; 28 + #now: string; 29 + #next: string; 30 + 31 + private constructor(key: CryptoKey, counter: number, prev: string, now: string, next: string) { 32 + this.#key = key; 33 + this.#rotationInterval = ROTATION_INTERVAL; 34 + this.#counter = counter; 35 + this.#prev = prev; 36 + this.#now = now; 37 + this.#next = next; 38 + } 39 + 40 + /** 41 + * creates a new DpopNonce instance. 42 + * 43 + * @param secret optional secret for nonce generation. if not provided, a 44 + * random secret will be generated. use a shared secret for multi-instance 45 + * deployments. 46 + * @returns promise resolving to the DpopNonce instance 47 + */ 48 + static async create(secret?: DpopSecret): Promise<DpopNonce> { 49 + const secretBytes = parseSecret(secret); 50 + const key = await crypto.subtle.importKey('raw', secretBytes, { name: 'HMAC', hash: 'SHA-256' }, false, [ 51 + 'sign', 52 + ]); 53 + 54 + const counter = getCurrentCounter(ROTATION_INTERVAL); 55 + 56 + // pre-compute initial nonces 57 + const [prev, now, next] = await Promise.all([ 58 + computeNonce(key, counter - 1), 59 + computeNonce(key, counter), 60 + computeNonce(key, counter + 1), 61 + ]); 62 + 63 + return new DpopNonce(key, counter, prev, now, next); 64 + } 65 + 66 + /** 67 + * returns the next nonce to include in the DPoP-Nonce response header. 68 + */ 69 + async next(): Promise<string> { 70 + await this.#rotate(); 71 + return this.#next; 72 + } 73 + 74 + /** 75 + * validates a nonce from a DPoP proof. 76 + * 77 + * @param nonce the nonce to validate 78 + * @returns true if the nonce matches prev, now, or next 79 + */ 80 + async check(nonce: string): Promise<boolean> { 81 + await this.#rotate(); 82 + 83 + return nonce === this.#prev || nonce === this.#now || nonce === this.#next; 84 + } 85 + 86 + async #rotate(): Promise<void> { 87 + const counter = getCurrentCounter(this.#rotationInterval); 88 + const diff = counter - this.#counter; 89 + 90 + if (diff === 0) { 91 + return; 92 + } 93 + 94 + if (diff === 1) { 95 + // optimize: shift window by one 96 + this.#prev = this.#now; 97 + this.#now = this.#next; 98 + this.#next = await this.#compute(counter + 1); 99 + } else if (diff === 2) { 100 + // optimize: reuse #next as #prev 101 + this.#prev = this.#next; 102 + this.#now = await this.#compute(counter); 103 + this.#next = await this.#compute(counter + 1); 104 + } else { 105 + // all nonces outdated, recompute all 106 + [this.#prev, this.#now, this.#next] = await Promise.all([ 107 + this.#compute(counter - 1), 108 + this.#compute(counter), 109 + this.#compute(counter + 1), 110 + ]); 111 + } 112 + 113 + this.#counter = counter; 114 + } 115 + 116 + async #compute(counter: number): Promise<string> { 117 + return computeNonce(this.#key, counter); 118 + } 119 + } 120 + 121 + function getCurrentCounter(interval: number): number { 122 + return (Date.now() / interval) | 0; 123 + } 124 + 125 + function parseSecret(secret: DpopSecret | undefined): Uint8Array<ArrayBuffer> { 126 + if (secret === undefined) { 127 + return randomBytes(SECRET_BYTE_LENGTH); 128 + } 129 + 130 + if (secret instanceof Uint8Array) { 131 + if (secret.length !== SECRET_BYTE_LENGTH) { 132 + throw new TypeError(`secret must be exactly ${SECRET_BYTE_LENGTH} bytes`); 133 + } 134 + 135 + return secret; 136 + } 137 + 138 + if (typeof secret === 'string') { 139 + if (secret.length !== SECRET_BYTE_LENGTH * 2 || !/^[0-9a-f]+$/i.test(secret)) { 140 + throw new TypeError(`secret must be a ${SECRET_BYTE_LENGTH * 2} character hex string`); 141 + } 142 + const bytes = new Uint8Array(SECRET_BYTE_LENGTH); 143 + for (let i = 0; i < SECRET_BYTE_LENGTH; i++) { 144 + bytes[i] = parseInt(secret.slice(i * 2, i * 2 + 2), 16); 145 + } 146 + 147 + return bytes; 148 + } 149 + 150 + throw new TypeError('secret must be a Uint8Array or hex string'); 151 + } 152 + 153 + async function computeNonce(key: CryptoKey, counter: number): Promise<string> { 154 + const data = new ArrayBuffer(8); 155 + const view = new DataView(data); 156 + // write counter as 64-bit big-endian (only lower 32 bits used for practical purposes) 157 + view.setUint32(0, 0, false); 158 + view.setUint32(4, counter >>> 0, false); 159 + 160 + const signature = await crypto.subtle.sign('HMAC', key, data); 161 + return toBase64Url(new Uint8Array(signature)); 162 + }
+203
packages/oauth/cab/lib/server/dpop-verifier.ts
··· 1 + import * as v from '@badrap/valita'; 2 + import { fromBase64Url, toBase64Url } from '@atcute/multibase'; 3 + import { decodeUtf8From, encodeUtf8, toSha256 } from '@atcute/uint8array'; 4 + import { importJWK, jwtVerify } from 'jose'; 5 + 6 + import type { DpopNonce } from './dpop-nonce.js'; 7 + 8 + // #region schemas 9 + 10 + const jwkEcSchema = v.object({ 11 + kty: v.literal('EC'), 12 + crv: v.union(v.literal('P-256'), v.literal('P-384'), v.literal('P-521')), 13 + x: v.string(), 14 + y: v.string(), 15 + }); 16 + 17 + const jwkRsaSchema = v.object({ 18 + kty: v.literal('RSA'), 19 + e: v.string(), 20 + n: v.string(), 21 + }); 22 + 23 + const jwkOkpSchema = v.object({ 24 + kty: v.literal('OKP'), 25 + crv: v.union(v.literal('Ed25519'), v.literal('Ed448')), 26 + x: v.string(), 27 + }); 28 + 29 + const dpopJwkSchema = v.union(jwkEcSchema, jwkRsaSchema, jwkOkpSchema); 30 + 31 + const dpopHeaderSchema = v.object({ 32 + typ: v.literal('dpop+jwt'), 33 + alg: v.string().assert((alg) => alg !== 'none', 'alg must not be "none"'), 34 + jwk: dpopJwkSchema, 35 + }); 36 + 37 + const dpopPayloadSchema = v.object({ 38 + htm: v.string(), 39 + htu: v.string(), 40 + iat: v.number(), 41 + jti: v.string(), 42 + nonce: v.string().optional(), 43 + }); 44 + 45 + // #endregion 46 + 47 + export type DPoPJwk = v.Infer<typeof dpopJwkSchema>; 48 + export type DPoPClaims = v.Infer<typeof dpopPayloadSchema>; 49 + 50 + /** 51 + * result of successful DPoP verification 52 + */ 53 + export interface DPoPVerifyResult { 54 + /** the verified claims */ 55 + claims: DPoPClaims; 56 + /** JWK thumbprint (base64url-encoded SHA-256 of canonical JWK) */ 57 + jkt: string; 58 + /** the public JWK from the proof */ 59 + jwk: DPoPJwk; 60 + } 61 + 62 + /** 63 + * options for DPoP verification 64 + */ 65 + export interface DPoPVerifyOptions { 66 + /** expected HTTP method (e.g., 'POST') */ 67 + method: string; 68 + /** expected HTTP target URI (origin + pathname) */ 69 + url: string; 70 + /** optional nonce manager for validation */ 71 + nonce?: DpopNonce; 72 + /** maximum allowed clock skew in seconds (default: 60) */ 73 + maxClockSkew?: number; 74 + } 75 + 76 + /** 77 + * error thrown when DPoP verification fails 78 + */ 79 + export class DPoPVerifyError extends Error { 80 + constructor( 81 + message: string, 82 + public code: 'missing' | 'invalid' | 'expired' | 'nonce_required', 83 + ) { 84 + super(message); 85 + this.name = 'DPoPVerifyError'; 86 + } 87 + } 88 + 89 + /** 90 + * computes the JWK thumbprint (RFC 7638) for a public key. 91 + * 92 + * @param jwk the public JWK 93 + * @returns base64url-encoded SHA-256 thumbprint 94 + */ 95 + export const computeJktFromJwk = async (jwk: DPoPJwk): Promise<string> => { 96 + const { kty } = jwk; 97 + 98 + // build canonical JWK based on key type (RFC 7638) 99 + let canonical: Record<string, string>; 100 + if (kty === 'EC') { 101 + const { crv, x, y } = jwk; 102 + canonical = { crv, kty, x, y }; 103 + } else if (kty === 'RSA') { 104 + const { e, n } = jwk; 105 + canonical = { e, kty, n }; 106 + } else { 107 + const { crv, x } = jwk; 108 + canonical = { crv, kty, x }; 109 + } 110 + 111 + const serialized = JSON.stringify(canonical); 112 + const hash = await toSha256(encodeUtf8(serialized)); 113 + 114 + return toBase64Url(hash); 115 + }; 116 + 117 + /** 118 + * decodes a base64url string to JSON. 119 + */ 120 + const decodeBase64UrlJson = (str: string): unknown => { 121 + const bytes = fromBase64Url(str); 122 + return JSON.parse(decodeUtf8From(bytes)); 123 + }; 124 + 125 + /** 126 + * verifies a DPoP proof from a request header. 127 + * 128 + * @param dpopHeader the DPoP header value 129 + * @param options verification options 130 + * @returns verification result with claims and JWK thumbprint 131 + * @throws {DPoPVerifyError} if verification fails 132 + */ 133 + export const verifyDPoP = async ( 134 + dpopHeader: string | null | undefined, 135 + options: DPoPVerifyOptions, 136 + ): Promise<DPoPVerifyResult> => { 137 + if (!dpopHeader) { 138 + throw new DPoPVerifyError('missing DPoP header', 'missing'); 139 + } 140 + 141 + const { method, url, nonce: dpopNonce, maxClockSkew = 60 } = options; 142 + 143 + // parse the JWT 144 + const parts = dpopHeader.split('.'); 145 + if (parts.length !== 3) { 146 + throw new DPoPVerifyError('invalid DPoP proof format', 'invalid'); 147 + } 148 + 149 + // parse and validate header 150 + let header: v.Infer<typeof dpopHeaderSchema>; 151 + try { 152 + const raw = decodeBase64UrlJson(parts[0]); 153 + header = dpopHeaderSchema.parse(raw, { mode: 'passthrough' }); 154 + } catch { 155 + throw new DPoPVerifyError('invalid DPoP header', 'invalid'); 156 + } 157 + 158 + const { jwk, alg } = header; 159 + 160 + // import the public key and verify the signature 161 + let payload: v.Infer<typeof dpopPayloadSchema>; 162 + try { 163 + const key = await importJWK(jwk, alg); 164 + const result = await jwtVerify(dpopHeader, key, { typ: 'dpop+jwt' }); 165 + payload = dpopPayloadSchema.parse(result.payload, { mode: 'passthrough' }); 166 + } catch (err) { 167 + if (err instanceof v.ValitaError) { 168 + throw new DPoPVerifyError('invalid DPoP payload', 'invalid'); 169 + } 170 + throw new DPoPVerifyError('DPoP signature verification failed', 'invalid'); 171 + } 172 + 173 + const { htm, htu, iat, nonce: proofNonce } = payload; 174 + 175 + // validate claims 176 + if (htm !== method) { 177 + throw new DPoPVerifyError(`DPoP htm mismatch: expected ${method}, got ${htm}`, 'invalid'); 178 + } 179 + 180 + if (htu !== url) { 181 + throw new DPoPVerifyError(`DPoP htu mismatch: expected ${url}, got ${htu}`, 'invalid'); 182 + } 183 + 184 + const now = Math.floor(Date.now() / 1000); 185 + if (iat > now + maxClockSkew) { 186 + throw new DPoPVerifyError('DPoP proof issued in the future', 'invalid'); 187 + } 188 + if (iat < now - maxClockSkew) { 189 + throw new DPoPVerifyError('DPoP proof expired', 'expired'); 190 + } 191 + 192 + // validate nonce if configured 193 + if (dpopNonce) { 194 + if (!proofNonce || !(await dpopNonce.check(proofNonce))) { 195 + throw new DPoPVerifyError('invalid or missing DPoP nonce', 'nonce_required'); 196 + } 197 + } 198 + 199 + // compute JWK thumbprint 200 + const jkt = await computeJktFromJwk(jwk); 201 + 202 + return { claims: payload, jkt, jwk }; 203 + };
+119
packages/oauth/cab/lib/server/handler.ts
··· 1 + import type { Keyset } from '@atcute/oauth-keyset'; 2 + import { 3 + createXrpcHandler, 4 + InvalidRequestError, 5 + json, 6 + type ProcedureConfig, 7 + type XRPCRouter, 8 + } from '@atcute/xrpc-server'; 9 + 10 + import { DevAtcuteOauthGetClientAssertion } from '../lexicons/index.js'; 11 + 12 + import { createClientAssertion } from './client-assertion.js'; 13 + import { DpopNonce, type DpopSecret } from './dpop-nonce.js'; 14 + import { DPoPVerifyError, verifyDPoP } from './dpop-verifier.js'; 15 + 16 + const CAB_PATH = '/xrpc/dev.atcute.oauth.getClientAssertion'; 17 + 18 + /** 19 + * options for creating a CAB handler 20 + */ 21 + export interface CabOptions { 22 + /** OAuth client ID */ 23 + clientId: string; 24 + /** client's private keyset */ 25 + keyset: Keyset; 26 + /** 27 + * DPoP nonce secret for replay protection. 28 + * - `undefined`: generate random secret (single instance) 29 + * - `string | Uint8Array`: shared secret (multi-instance) 30 + * - `false`: disable nonce requirement 31 + */ 32 + dpopSecret?: DpopSecret | false; 33 + /** optional algorithms supported by the server (for key selection) */ 34 + serverAlgs?: readonly string[]; 35 + } 36 + 37 + const createCabProcedure = async ( 38 + options: CabOptions, 39 + ): Promise<ProcedureConfig<DevAtcuteOauthGetClientAssertion.mainSchema>> => { 40 + const { clientId, keyset, dpopSecret, serverAlgs } = options; 41 + 42 + const dpopNonce = dpopSecret === false ? undefined : await DpopNonce.create(dpopSecret); 43 + 44 + return { 45 + async handler({ request, input: { aud } }) { 46 + const url = new URL(request.url); 47 + const htu = url.origin + url.pathname; 48 + 49 + // get fresh nonce for response headers 50 + const nextNonce = dpopNonce ? await dpopNonce.next() : undefined; 51 + const headers: HeadersInit | undefined = nextNonce ? { 'DPoP-Nonce': nextNonce } : undefined; 52 + 53 + // verify DPoP proof (includes nonce validation if configured) 54 + let jkt: string; 55 + try { 56 + const result = await verifyDPoP(request.headers.get('dpop'), { 57 + method: 'POST', 58 + url: htu, 59 + nonce: dpopNonce, 60 + }); 61 + jkt = result.jkt; 62 + } catch (err) { 63 + if (err instanceof DPoPVerifyError) { 64 + const error = err.code === 'nonce_required' ? 'UseDpopNonce' : 'InvalidDpopProof'; 65 + throw new InvalidRequestError({ error, headers }); 66 + } 67 + throw err; 68 + } 69 + 70 + // create client assertion 71 + const assertion = await createClientAssertion({ 72 + clientId, 73 + audience: aud, 74 + jkt, 75 + keyset, 76 + serverAlgs, 77 + }); 78 + 79 + return json({ client_assertion: assertion.client_assertion }, { headers }); 80 + }, 81 + }; 82 + }; 83 + 84 + /** 85 + * registers the CAB procedure to an existing XRPC router. 86 + * 87 + * @param router XRPC router to register to 88 + * @param options handler configuration 89 + */ 90 + export const registerCab = async (router: XRPCRouter, options: CabOptions): Promise<void> => { 91 + const config = await createCabProcedure(options); 92 + router.addProcedure(DevAtcuteOauthGetClientAssertion.mainSchema, config); 93 + }; 94 + 95 + /** 96 + * creates a standalone CAB handler. 97 + * 98 + * returns `undefined` for non-matching requests, allowing use as middleware. 99 + * 100 + * @param options handler configuration 101 + * @returns fetch handler that returns `Response` or `undefined` if path doesn't match 102 + */ 103 + export const createCabHandler = async ( 104 + options: CabOptions, 105 + ): Promise<(request: Request) => Promise<Response> | undefined> => { 106 + const config = await createCabProcedure(options); 107 + const handler = createXrpcHandler({ 108 + lxm: DevAtcuteOauthGetClientAssertion.mainSchema, 109 + ...config, 110 + }); 111 + 112 + return (request: Request): Promise<Response> | undefined => { 113 + const url = new URL(request.url); 114 + if (url.pathname !== CAB_PATH) { 115 + return undefined; 116 + } 117 + return handler(request); 118 + }; 119 + };
+41
packages/oauth/cab/lib/server/index.ts
··· 1 + export { 2 + exportJwkKey, 3 + exportPkcs8Key, 4 + generatePrivateKey, 5 + importJwkKey, 6 + importPkcs8Key, 7 + Keyset, 8 + type ImportKeyOptions, 9 + type KeySearchOptions, 10 + type PrivateKey, 11 + type SigningAlgorithm, 12 + } from '@atcute/oauth-keyset'; 13 + 14 + export { 15 + buildClientMetadata, 16 + CLIENT_ASSERTION_TYPE_JWT_BEARER, 17 + FALLBACK_ALG, 18 + type AtprotoAuthorizationServerMetadata, 19 + type AtprotoProtectedResourceMetadata, 20 + type ConfidentialClientMetadata, 21 + type OAuthAuthorizationServerMetadata, 22 + type OAuthClientMetadata, 23 + type OAuthProtectedResourceMetadata, 24 + type OAuthResponseMode, 25 + } from '@atcute/oauth-types'; 26 + 27 + export { 28 + createClientAssertion, 29 + type ClientAssertionResult, 30 + type CreateClientAssertionOptions, 31 + } from './client-assertion.js'; 32 + export { 33 + computeJktFromJwk, 34 + DPoPVerifyError, 35 + verifyDPoP, 36 + type DPoPClaims, 37 + type DPoPVerifyOptions, 38 + type DPoPVerifyResult, 39 + } from './dpop-verifier.js'; 40 + export { createCabHandler, registerCab, type CabOptions } from './handler.js'; 41 + export type { DpopSecret } from './dpop-nonce.js';
+58
packages/oauth/cab/package.json
··· 1 + { 2 + "type": "module", 3 + "name": "@atcute/oauth-cab", 4 + "version": "0.1.0", 5 + "description": "Client Assertion Backend (CAB) for AT Protocol OAuth browser clients", 6 + "license": "0BSD", 7 + "repository": { 8 + "url": "https://github.com/mary-ext/atcute", 9 + "directory": "packages/oauth/cab" 10 + }, 11 + "publishConfig": { 12 + "access": "public" 13 + }, 14 + "files": [ 15 + "dist/", 16 + "lib/", 17 + "!lib/**/*.bench.ts", 18 + "!lib/**/*.test.ts" 19 + ], 20 + "exports": { 21 + "./client": "./dist/client/index.js", 22 + "./server": "./dist/server/index.js" 23 + }, 24 + "sideEffects": false, 25 + "scripts": { 26 + "build": "tsgo -b", 27 + "generate": "rm -r ./lib/lexicons/; lex-cli generate", 28 + "export": "lex-cli export", 29 + "test": "vitest", 30 + "prepublish": "rm -rf dist; pnpm run build" 31 + }, 32 + "dependencies": { 33 + "@badrap/valita": "^0.4.6", 34 + "@atcute/client": "workspace:^", 35 + "@atcute/lexicons": "workspace:^", 36 + "@atcute/multibase": "workspace:^", 37 + "@atcute/oauth-keyset": "workspace:^", 38 + "@atcute/oauth-types": "workspace:^", 39 + "@atcute/uint8array": "workspace:^", 40 + "@atcute/xrpc-server": "workspace:^", 41 + "jose": "^6.1.3", 42 + "nanoid": "^5.1.6" 43 + }, 44 + "devDependencies": { 45 + "@atcute/lex-cli": "workspace:^", 46 + "@atcute/lexicon-doc": "workspace:^", 47 + "@atcute/oauth-cab": "file:", 48 + "vitest": "^4.0.16" 49 + }, 50 + "peerDependencies": { 51 + "@atcute/oauth-browser-client": "workspace:^" 52 + }, 53 + "peerDependenciesMeta": { 54 + "@atcute/oauth-browser-client": { 55 + "optional": true 56 + } 57 + } 58 + }
+4
packages/oauth/cab/tsconfig.json
··· 1 + { 2 + "files": [], 3 + "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.lexicons.json" }] 4 + }
+19
packages/oauth/cab/tsconfig.lexicons.json
··· 1 + { 2 + "compilerOptions": { 3 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.lexicons.tsbuildinfo", 4 + "target": "ESNext", 5 + "module": "NodeNext", 6 + "moduleResolution": "NodeNext", 7 + "allowImportingTsExtensions": true, 8 + "verbatimModuleSyntax": true, 9 + "moduleDetection": "force", 10 + "isolatedModules": true, 11 + "skipLibCheck": true, 12 + "noEmit": true, 13 + "strict": true, 14 + "noUnusedLocals": true, 15 + "noUnusedParameters": true, 16 + "noFallthroughCasesInSwitch": true 17 + }, 18 + "include": ["lexicons-src"] 19 + }
+24
packages/oauth/cab/tsconfig.lib.json
··· 1 + { 2 + "compilerOptions": { 3 + "outDir": "dist/", 4 + "esModuleInterop": true, 5 + "skipLibCheck": true, 6 + "target": "ESNext", 7 + "allowJs": true, 8 + "resolveJsonModule": true, 9 + "moduleDetection": "force", 10 + "isolatedModules": true, 11 + "verbatimModuleSyntax": true, 12 + "strict": true, 13 + "noImplicitOverride": true, 14 + "noUnusedLocals": true, 15 + "noUnusedParameters": true, 16 + "noFallthroughCasesInSwitch": true, 17 + "module": "NodeNext", 18 + "sourceMap": true, 19 + "declaration": true, 20 + "declarationMap": true 21 + }, 22 + "include": ["lib"], 23 + "exclude": ["lib/**/*.test.ts", "lib/**/*.bench.ts"] 24 + }
+107
pnpm-lock.yaml
··· 764 764 '@atcute/multibase': 765 765 specifier: workspace:^ 766 766 version: link:../../utilities/multibase 767 + '@atcute/oauth-types': 768 + specifier: workspace:^ 769 + version: link:../types 767 770 '@atcute/uint8array': 768 771 specifier: workspace:^ 769 772 version: link:../../misc/uint8array ··· 771 774 specifier: ^5.1.6 772 775 version: 5.1.6 773 776 777 + packages/oauth/cab: 778 + dependencies: 779 + '@atcute/client': 780 + specifier: workspace:^ 781 + version: link:../../clients/client 782 + '@atcute/lexicons': 783 + specifier: workspace:^ 784 + version: link:../../lexicons/lexicons 785 + '@atcute/multibase': 786 + specifier: workspace:^ 787 + version: link:../../utilities/multibase 788 + '@atcute/oauth-browser-client': 789 + specifier: workspace:^ 790 + version: link:../browser-client 791 + '@atcute/oauth-keyset': 792 + specifier: workspace:^ 793 + version: link:../keyset 794 + '@atcute/oauth-types': 795 + specifier: workspace:^ 796 + version: link:../types 797 + '@atcute/uint8array': 798 + specifier: workspace:^ 799 + version: link:../../misc/uint8array 800 + '@atcute/xrpc-server': 801 + specifier: workspace:^ 802 + version: link:../../servers/xrpc-server 803 + '@badrap/valita': 804 + specifier: ^0.4.6 805 + version: 0.4.6 806 + jose: 807 + specifier: ^6.1.3 808 + version: 6.1.3 809 + nanoid: 810 + specifier: ^5.1.6 811 + version: 5.1.6 812 + devDependencies: 813 + '@atcute/lex-cli': 814 + specifier: workspace:^ 815 + version: link:../../lexicons/lex-cli 816 + '@atcute/lexicon-doc': 817 + specifier: workspace:^ 818 + version: link:../../lexicons/lexicon-doc 819 + '@atcute/oauth-cab': 820 + specifier: 'file:' 821 + version: file:packages/oauth/cab(@atcute/oauth-browser-client@packages+oauth+browser-client) 822 + vitest: 823 + specifier: ^4.0.16 824 + version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 825 + 826 + packages/oauth/keyset: 827 + dependencies: 828 + jose: 829 + specifier: ^6.1.3 830 + version: 6.1.3 831 + devDependencies: 832 + vitest: 833 + specifier: ^4.0.16 834 + version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 835 + 774 836 packages/oauth/node-client: 775 837 dependencies: 776 838 '@atcute/client': ··· 788 850 '@atcute/multibase': 789 851 specifier: workspace:^ 790 852 version: link:../../utilities/multibase 853 + '@atcute/oauth-keyset': 854 + specifier: workspace:^ 855 + version: link:../keyset 856 + '@atcute/oauth-types': 857 + specifier: workspace:^ 858 + version: link:../types 791 859 '@atcute/uint8array': 792 860 specifier: workspace:^ 793 861 version: link:../../misc/uint8array ··· 841 909 '@types/bun': 842 910 specifier: latest 843 911 version: 1.3.5 912 + 913 + packages/oauth/types: 914 + dependencies: 915 + '@atcute/identity': 916 + specifier: workspace:^ 917 + version: link:../../identity/identity 918 + '@atcute/oauth-keyset': 919 + specifier: workspace:^ 920 + version: link:../keyset 921 + '@badrap/valita': 922 + specifier: ^0.4.6 923 + version: 0.4.6 924 + devDependencies: 925 + vitest: 926 + specifier: ^4.0.16 927 + version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0) 844 928 845 929 packages/servers/xrpc-server: 846 930 dependencies: ··· 1174 1258 1175 1259 '@atcute/microcosm@file:packages/definitions/microcosm': 1176 1260 resolution: {directory: packages/definitions/microcosm, type: directory} 1261 + 1262 + '@atcute/oauth-cab@file:packages/oauth/cab': 1263 + resolution: {directory: packages/oauth/cab, type: directory} 1264 + peerDependencies: 1265 + '@atcute/oauth-browser-client': workspace:^ 1266 + peerDependenciesMeta: 1267 + '@atcute/oauth-browser-client': 1268 + optional: true 1177 1269 1178 1270 '@atcute/ozone@file:packages/definitions/ozone': 1179 1271 resolution: {directory: packages/definitions/ozone, type: directory} ··· 4457 4549 '@atcute/microcosm@file:packages/definitions/microcosm': 4458 4550 dependencies: 4459 4551 '@atcute/lexicons': link:packages/lexicons/lexicons 4552 + 4553 + '@atcute/oauth-cab@file:packages/oauth/cab(@atcute/oauth-browser-client@packages+oauth+browser-client)': 4554 + dependencies: 4555 + '@atcute/client': link:packages/clients/client 4556 + '@atcute/lexicons': link:packages/lexicons/lexicons 4557 + '@atcute/multibase': link:packages/utilities/multibase 4558 + '@atcute/oauth-keyset': link:packages/oauth/keyset 4559 + '@atcute/oauth-types': link:packages/oauth/types 4560 + '@atcute/uint8array': link:packages/misc/uint8array 4561 + '@atcute/xrpc-server': link:packages/servers/xrpc-server 4562 + '@badrap/valita': 0.4.6 4563 + jose: 6.1.3 4564 + nanoid: 5.1.6 4565 + optionalDependencies: 4566 + '@atcute/oauth-browser-client': link:packages/oauth/browser-client 4460 4567 4461 4568 '@atcute/ozone@file:packages/definitions/ozone': 4462 4569 dependencies: