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

Mary af4ac7fe cd3b1b2f

+533
+75
packages/oauth/keyset/README.md
··· 1 + # @atcute/oauth-keyset 2 + 3 + keyset management for AT Protocol OAuth. 4 + 5 + ## installation 6 + 7 + ```sh 8 + npm install @atcute/oauth-keyset 9 + ``` 10 + 11 + ## usage 12 + 13 + ### generating keys 14 + 15 + ```ts 16 + import { generatePrivateKey, Keyset } from '@atcute/oauth-keyset'; 17 + 18 + // generate a new ES256 key 19 + const key = await generatePrivateKey('my-key-id'); 20 + 21 + // create a keyset with the key 22 + const keyset = new Keyset([key]); 23 + ``` 24 + 25 + ### importing keys 26 + 27 + ```ts 28 + import { importJwkKey, importPkcs8Key, Keyset } from '@atcute/oauth-keyset'; 29 + 30 + // import from JWK 31 + const jwkKey = await importJwkKey({ 32 + kty: 'EC', 33 + crv: 'P-256', 34 + kid: 'my-key', 35 + // ... private key parameters 36 + }); 37 + 38 + // import from PKCS#8 PEM 39 + const pemKey = await importPkcs8Key(pemString, { 40 + kid: 'my-key', 41 + alg: 'ES256', 42 + }); 43 + 44 + const keyset = new Keyset([jwkKey, pemKey]); 45 + ``` 46 + 47 + ### exporting keys 48 + 49 + ```ts 50 + import { exportJwkKey, exportPkcs8Key } from '@atcute/oauth-keyset'; 51 + 52 + // export to JWK 53 + const jwk = await exportJwkKey(key); 54 + 55 + // export to PKCS#8 PEM 56 + const pem = await exportPkcs8Key(key); 57 + ``` 58 + 59 + ### using the keyset 60 + 61 + ```ts 62 + // get public JWKS (for serving at jwks_uri) 63 + const jwks = keyset.publicJwks; 64 + 65 + // find a key by criteria 66 + const key = keyset.find({ kid: 'my-key' }); 67 + const key = keyset.find({ alg: 'ES256' }); 68 + 69 + // find a key for signing with server negotiation 70 + const { key, alg } = keyset.findForSigning(['ES256', 'ES384']); 71 + ``` 72 + 73 + ## license 74 + 75 + 0BSD
+198
packages/oauth/keyset/lib/import-key.ts
··· 1 + import { type JWK, exportJWK, exportPKCS8, generateKeyPair, importJWK, importPKCS8 } from 'jose'; 2 + 3 + import type { ImportKeyOptions, PrivateKey, SigningAlgorithm } from './types.js'; 4 + 5 + const SIGNING_ALGORITHMS: readonly SigningAlgorithm[] = [ 6 + 'ES256', 7 + 'ES384', 8 + 'ES512', 9 + 'PS256', 10 + 'PS384', 11 + 'PS512', 12 + 'RS256', 13 + 'RS384', 14 + 'RS512', 15 + ]; 16 + 17 + /** map EC curve to default algorithm */ 18 + const CURVE_TO_ALG: Record<string, SigningAlgorithm> = { 19 + 'P-256': 'ES256', 20 + 'P-384': 'ES384', 21 + 'P-521': 'ES512', 22 + }; 23 + 24 + const isSigningAlgorithm = (alg: string): alg is SigningAlgorithm => { 25 + return (SIGNING_ALGORITHMS as readonly string[]).includes(alg); 26 + }; 27 + 28 + /** 29 + * generates a new private key for use with `private_key_jwt`. 30 + * 31 + * @param kid key ID to assign to the generated key 32 + * @param alg signing algorithm (defaults to 'ES256') 33 + * @returns private key ready for use in keyset 34 + */ 35 + export const generatePrivateKey = async ( 36 + kid: string, 37 + alg: SigningAlgorithm = 'ES256', 38 + ): Promise<PrivateKey> => { 39 + const { privateKey } = await generateKeyPair(alg, { extractable: true }); 40 + const jwk = await exportJWK(privateKey); 41 + jwk.alg = alg; 42 + jwk.kid = kid; 43 + 44 + const publicJwk = derivePublicJwk(jwk, kid, alg); 45 + 46 + return { kid, alg, key: privateKey, publicJwk }; 47 + }; 48 + 49 + /** 50 + * imports a private key from a JWK object or JSON string. 51 + * 52 + * @param input JWK object or JSON string containing a JWK 53 + * @param options override or provide `kid` and `alg` 54 + * @returns private key ready for use in keyset 55 + * @throws if `kid` cannot be determined, `alg` cannot be determined/inferred, 56 + * or the key format is invalid 57 + * 58 + * resolution order: 59 + * - `kid`: `options.kid` ?? `input.kid` ?? error 60 + * - `alg`: `options.alg` ?? `input.alg` ?? inferred from curve ?? error 61 + * 62 + * algorithm inference (EC keys only): 63 + * - P-256 -> ES256, P-384 -> ES384, P-521 -> ES512 64 + * - RSA keys require explicit `alg` (no inference possible) 65 + */ 66 + export const importJwkKey = async (input: JWK | string, options?: ImportKeyOptions): Promise<PrivateKey> => { 67 + let jwk: JWK; 68 + 69 + if (typeof input === 'string') { 70 + try { 71 + jwk = JSON.parse(input) as JWK; 72 + } catch { 73 + throw new Error(`invalid JSON string`); 74 + } 75 + } else if (typeof input === 'object' && input !== null && 'kty' in input) { 76 + jwk = input; 77 + } else { 78 + throw new Error(`invalid input: expected JWK object or JSON string`); 79 + } 80 + 81 + // resolve kid 82 + const kid = options?.kid ?? jwk.kid; 83 + if (!kid) { 84 + throw new Error(`kid is required: provide via options or include in JWK`); 85 + } 86 + 87 + // resolve alg 88 + let alg = options?.alg ?? jwk.alg; 89 + if (!alg) { 90 + // try to infer from EC curve 91 + const crv = (jwk as { crv?: string }).crv; 92 + if (crv && crv in CURVE_TO_ALG) { 93 + alg = CURVE_TO_ALG[crv]; 94 + } else { 95 + throw new Error( 96 + `alg is required: provide via options, include in JWK, or use an EC key with a known curve`, 97 + ); 98 + } 99 + } 100 + 101 + if (!isSigningAlgorithm(alg)) { 102 + throw new Error(`unsupported algorithm: ${alg}`); 103 + } 104 + 105 + // verify this is a private key (has 'd' parameter for asymmetric keys) 106 + if (!('d' in jwk) || !jwk.d) { 107 + throw new Error(`expected a private key (missing 'd' parameter)`); 108 + } 109 + 110 + // import the JWK 111 + const imported = await importJWK(jwk, alg); 112 + if (!(imported instanceof CryptoKey)) { 113 + throw new Error(`expected asymmetric key, got symmetric`); 114 + } 115 + 116 + // derive public JWK by removing private components 117 + const publicJwk = derivePublicJwk(jwk, kid, alg); 118 + 119 + return { kid, alg, key: imported, publicJwk }; 120 + }; 121 + 122 + /** 123 + * imports a private key from a PKCS#8 PEM string. 124 + * 125 + * @param pem PKCS#8 PEM string (starts with '-----BEGIN PRIVATE KEY-----') 126 + * @param options must include `kid` and `alg` 127 + * @returns private key ready for use in keyset 128 + */ 129 + export const importPkcs8Key = async ( 130 + pem: string, 131 + options: Required<ImportKeyOptions>, 132 + ): Promise<PrivateKey> => { 133 + const { kid, alg } = options; 134 + 135 + if (!isSigningAlgorithm(alg)) { 136 + throw new Error(`unsupported algorithm: ${alg}`); 137 + } 138 + 139 + const imported = await importPKCS8(pem, alg, { extractable: true }); 140 + if (!(imported instanceof CryptoKey)) { 141 + throw new Error(`expected asymmetric key, got symmetric`); 142 + } 143 + 144 + const jwk = await exportJWK(imported); 145 + jwk.alg = alg; 146 + jwk.kid = kid; 147 + 148 + const publicJwk = derivePublicJwk(jwk, kid, alg); 149 + 150 + return { kid, alg, key: imported, publicJwk }; 151 + }; 152 + 153 + /** 154 + * exports a private key to JWK format. 155 + * 156 + * @param key private key to export 157 + * @returns JWK with `kid` and `alg` set 158 + */ 159 + export const exportJwkKey = async (key: PrivateKey): Promise<JWK> => { 160 + const jwk = await exportJWK(key.key); 161 + jwk.kid = key.kid; 162 + jwk.alg = key.alg; 163 + return jwk; 164 + }; 165 + 166 + /** 167 + * exports a private key to PKCS#8 PEM format. 168 + * 169 + * @param key private key to export 170 + * @returns PKCS#8 PEM string 171 + */ 172 + export const exportPkcs8Key = async (key: PrivateKey): Promise<string> => { 173 + return exportPKCS8(key.key); 174 + }; 175 + 176 + /** 177 + * derives a public JWK from a private JWK by removing private key material. 178 + */ 179 + const derivePublicJwk = (privateJwk: JWK, kid: string, alg: string): JWK => { 180 + const { kty } = privateJwk; 181 + 182 + if (kty === 'EC') { 183 + const { crv, x, y } = privateJwk as JWK & { crv: string; x: string; y: string }; 184 + return { kty, crv, x, y, kid, alg, use: 'sig' }; 185 + } 186 + 187 + if (kty === 'RSA') { 188 + const { n, e } = privateJwk as JWK & { n: string; e: string }; 189 + return { kty, n, e, kid, alg, use: 'sig' }; 190 + } 191 + 192 + if (kty === 'OKP') { 193 + const { crv, x } = privateJwk as JWK & { crv: string; x: string }; 194 + return { kty, crv, x, kid, alg, use: 'sig' }; 195 + } 196 + 197 + throw new Error(`unsupported key type: ${kty}`); 198 + };
+9
packages/oauth/keyset/lib/index.ts
··· 1 + export { 2 + exportJwkKey, 3 + exportPkcs8Key, 4 + generatePrivateKey, 5 + importJwkKey, 6 + importPkcs8Key, 7 + } from './import-key.js'; 8 + export { Keyset } from './keyset.js'; 9 + export type { ImportKeyOptions, KeySearchOptions, PrivateKey, SigningAlgorithm } from './types.js';
+141
packages/oauth/keyset/lib/keyset.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + import type { KeySearchOptions, PrivateKey } from './types.js'; 4 + 5 + /** 6 + * preferred algorithm order for signing. 7 + * EC algorithms first (smaller, faster), then PSS, then PKCS#1 v1.5. 8 + */ 9 + const PREFERRED_ALGORITHMS = [ 10 + 'ES256', 11 + 'ES384', 12 + 'ES512', 13 + 'PS256', 14 + 'PS384', 15 + 'PS512', 16 + 'RS256', 17 + 'RS384', 18 + 'RS512', 19 + ] as const; 20 + 21 + /** 22 + * a collection of private keys for client authentication. 23 + */ 24 + export class Keyset { 25 + private readonly keys: readonly PrivateKey[]; 26 + 27 + /** 28 + * creates a new keyset from an array of private keys. 29 + * 30 + * @param keys array of private keys (at least one required) 31 + * @throws if keyset is empty or contains duplicate key IDs 32 + */ 33 + constructor(keys: PrivateKey[]) { 34 + if (keys.length === 0) { 35 + throw new Error(`keyset must contain at least one key`); 36 + } 37 + 38 + // check for duplicate kids 39 + const kids = new Set<string>(); 40 + for (const key of keys) { 41 + if (kids.has(key.kid)) { 42 + throw new Error(`duplicate key ID: ${key.kid}`); 43 + } 44 + kids.add(key.kid); 45 + } 46 + 47 + this.keys = Object.freeze([...keys]); 48 + } 49 + 50 + /** number of keys in the keyset */ 51 + get size(): number { 52 + return this.keys.length; 53 + } 54 + 55 + /** 56 + * public JWKS for serving at client metadata or jwks_uri. 57 + * pre-computed at import time, safe to inline. 58 + */ 59 + get publicJwks(): { keys: readonly JWK[] } { 60 + return { keys: this.keys.map((k) => k.publicJwk) }; 61 + } 62 + 63 + /** 64 + * finds the first key matching the given criteria. 65 + * 66 + * @param options search criteria (kid and/or alg) 67 + * @returns matching key or undefined 68 + */ 69 + find(options?: KeySearchOptions): PrivateKey | undefined { 70 + for (const key of this.list(options)) { 71 + return key; 72 + } 73 + return undefined; 74 + } 75 + 76 + /** 77 + * gets a key matching the given criteria. 78 + * 79 + * @param options search criteria (kid and/or alg) 80 + * @returns matching key 81 + * @throws if no matching key is found 82 + */ 83 + get(options?: KeySearchOptions): PrivateKey { 84 + const key = this.find(options); 85 + if (!key) { 86 + const desc = options?.kid ?? options?.alg ?? 'any'; 87 + throw new Error(`no key found matching: ${desc}`); 88 + } 89 + return key; 90 + } 91 + 92 + /** 93 + * iterates over keys matching the given criteria, in preference order. 94 + * 95 + * @param options search criteria (kid and/or alg) 96 + */ 97 + *list(options?: KeySearchOptions): Generator<PrivateKey> { 98 + const { kid, alg } = options ?? {}; 99 + const algSet = alg == null ? null : new Set(Array.isArray(alg) ? alg : [alg]); 100 + 101 + // sort keys by algorithm preference 102 + const sorted = [...this.keys].sort((a, b) => { 103 + const aIdx = PREFERRED_ALGORITHMS.indexOf(a.alg as (typeof PREFERRED_ALGORITHMS)[number]); 104 + const bIdx = PREFERRED_ALGORITHMS.indexOf(b.alg as (typeof PREFERRED_ALGORITHMS)[number]); 105 + return aIdx - bIdx; 106 + }); 107 + 108 + for (const key of sorted) { 109 + if (kid != null && key.kid !== kid) { 110 + continue; 111 + } 112 + if (algSet != null && !algSet.has(key.alg)) { 113 + continue; 114 + } 115 + yield key; 116 + } 117 + } 118 + 119 + /** 120 + * finds a key for signing, negotiating algorithm with server's supported list. 121 + * 122 + * @param serverAlgs algorithms supported by the server (from metadata) 123 + * @returns key and negotiated algorithm 124 + * @throws if no compatible key is found 125 + */ 126 + findForSigning(serverAlgs?: readonly string[]): { key: PrivateKey; alg: string } { 127 + // if server doesn't specify, default to ES256 per atproto spec 128 + const algs = serverAlgs ?? ['ES256']; 129 + 130 + const key = this.find({ alg: algs }); 131 + if (!key) { 132 + throw new Error(`no key found compatible with server algorithms: ${algs.join(', ')}`); 133 + } 134 + 135 + return { key, alg: key.alg }; 136 + } 137 + 138 + [Symbol.iterator](): Iterator<PrivateKey> { 139 + return this.keys[Symbol.iterator](); 140 + } 141 + }
+47
packages/oauth/keyset/lib/types.ts
··· 1 + import type { JWK } from 'jose'; 2 + 3 + /** 4 + * signing algorithms supported by AT Protocol OAuth. 5 + * 6 + * @see {@link https://atproto.com/specs/oauth#confidential-client-authentication} 7 + */ 8 + export type SigningAlgorithm = 9 + | 'ES256' 10 + | 'ES384' 11 + | 'ES512' // EC (ES256 is spec minimum) 12 + | 'PS256' 13 + | 'PS384' 14 + | 'PS512' // RSA-PSS 15 + | 'RS256' 16 + | 'RS384' 17 + | 'RS512'; // RSA 18 + 19 + /** 20 + * private key for client authentication via `private_key_jwt`. 21 + */ 22 + export interface PrivateKey { 23 + /** key ID, required for `private_key_jwt` */ 24 + kid: string; 25 + /** signing algorithm */ 26 + alg: SigningAlgorithm; 27 + /** imported key object for signing */ 28 + key: CryptoKey; 29 + /** pre-computed public JWK for JWKS export */ 30 + publicJwk: JWK; 31 + } 32 + 33 + /** options for importing a private key */ 34 + export interface ImportKeyOptions { 35 + /** override or provide key ID */ 36 + kid?: string; 37 + /** override or provide algorithm */ 38 + alg?: SigningAlgorithm; 39 + } 40 + 41 + /** criteria for finding a key in a keyset */ 42 + export interface KeySearchOptions { 43 + /** find by specific key ID */ 44 + kid?: string; 45 + /** find by algorithm (single or array of acceptable algs) */ 46 + alg?: string | readonly string[]; 47 + }
+35
packages/oauth/keyset/package.json
··· 1 + { 2 + "type": "module", 3 + "name": "@atcute/oauth-keyset", 4 + "version": "0.1.0", 5 + "description": "keyset management for AT Protocol OAuth", 6 + "license": "0BSD", 7 + "repository": { 8 + "url": "https://github.com/mary-ext/atcute", 9 + "directory": "packages/oauth/keyset" 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 + ".": "./dist/index.js" 22 + }, 23 + "sideEffects": false, 24 + "scripts": { 25 + "build": "tsgo --project tsconfig.build.json", 26 + "test": "vitest", 27 + "prepublish": "rm -rf dist; pnpm run build" 28 + }, 29 + "dependencies": { 30 + "jose": "^6.1.3" 31 + }, 32 + "devDependencies": { 33 + "vitest": "^4.0.16" 34 + } 35 + }
+4
packages/oauth/keyset/tsconfig.build.json
··· 1 + { 2 + "extends": "./tsconfig.json", 3 + "exclude": ["lib/**/*.test.ts", "lib/**/*.bench.ts"] 4 + }
+24
packages/oauth/keyset/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "types": [], 4 + "outDir": "dist/", 5 + "esModuleInterop": true, 6 + "skipLibCheck": true, 7 + "target": "ESNext", 8 + "allowJs": true, 9 + "resolveJsonModule": true, 10 + "moduleDetection": "force", 11 + "isolatedModules": true, 12 + "verbatimModuleSyntax": true, 13 + "strict": true, 14 + "noImplicitOverride": true, 15 + "noUnusedLocals": true, 16 + "noUnusedParameters": true, 17 + "noFallthroughCasesInSwitch": true, 18 + "module": "NodeNext", 19 + "sourceMap": true, 20 + "declaration": true, 21 + "declarationMap": true 22 + }, 23 + "include": ["lib"] 24 + }