From f2af772787889e0df4a67d5ac5a300fbc08fe9aa Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 29 Feb 2024 09:31:39 +0100 Subject: [PATCH] wip --- packages/jwk-node/src/index.ts | 2 +- packages/jwk-node/src/node-key.ts | 127 +++++++++++ packages/jwk-node/src/node-keypair.ts | 173 -------------- packages/jwk-node/src/node-keyset.ts | 14 +- packages/jwk/package.json | 1 + packages/jwk/src/alg.ts | 97 ++++++++ packages/jwk/src/index.ts | 3 +- packages/jwk/src/key.ts | 98 ++++++++ packages/jwk/src/keypair.ts | 37 --- packages/jwk/src/keyset.ts | 213 +++++++++++++----- packages/jwk/src/types.ts | 3 + packages/jwk/src/util.ts | 203 ++++------------- .../src/client/client-manager.ts | 8 +- packages/oauth-provider/src/constants.ts | 23 +- .../oauth-provider/src/dpop/dpop-provider.ts | 54 +++++ packages/oauth-provider/src/index.ts | 1 + packages/oauth-provider/src/oauth-dpop.ts | 3 + packages/oauth-provider/src/oauth-provider.ts | 73 ++---- packages/oauth-provider/src/signer/signer.ts | 94 +++----- packages/pds/example.env | 3 +- .../helpers/used-refresh-token.ts | 11 +- packages/pds/src/account-manager/index.ts | 1 - packages/pds/src/api/proxy.ts | 21 +- packages/pds/src/auth-verifier.ts | 90 +++++++- packages/pds/src/config/env.ts | 2 + packages/pds/src/config/secrets.ts | 6 + packages/pds/src/context.ts | 64 ++++-- packages/pds/src/logger.ts | 9 + .../pds/src/oauth-provider/oauth-manager.ts | 23 +- pnpm-lock.yaml | 3 + .../17/did:plc:4ozujjsqicbdzuit2xnf3j2p/key | 1 + services/pds/index.js | 68 +++--- 32 files changed, 891 insertions(+), 638 deletions(-) create mode 100644 packages/jwk-node/src/node-key.ts delete mode 100644 packages/jwk-node/src/node-keypair.ts create mode 100644 packages/jwk/src/alg.ts create mode 100644 packages/jwk/src/key.ts delete mode 100644 packages/jwk/src/keypair.ts create mode 100644 packages/oauth-provider/src/dpop/dpop-provider.ts create mode 100644 packages/oauth-provider/src/oauth-dpop.ts create mode 100644 services/pds/actors/17/did:plc:4ozujjsqicbdzuit2xnf3j2p/key diff --git a/packages/jwk-node/src/index.ts b/packages/jwk-node/src/index.ts index 1df4bfca745..229b29b1078 100644 --- a/packages/jwk-node/src/index.ts +++ b/packages/jwk-node/src/index.ts @@ -1,2 +1,2 @@ -export * from './node-keypair.js' +export * from './node-key.js' export * from './node-keyset.js' diff --git a/packages/jwk-node/src/node-key.ts b/packages/jwk-node/src/node-key.ts new file mode 100644 index 00000000000..96dd314b2f3 --- /dev/null +++ b/packages/jwk-node/src/node-key.ts @@ -0,0 +1,127 @@ +import { KeyObject, createPublicKey } from 'node:crypto' + +import { + Jwk, + Key, + KeyLike, + either, + jwkPubSchema, + jwkSchema, +} from '@atproto/jwk' +import { exportJWK, importJWK, importPKCS8 } from 'jose' + +export type Importable = string | KeyObject | KeyLike | Jwk + +function asPublicJwk( + { kid, use, alg }: { kid?: string; use?: 'sig' | 'enc'; alg?: string }, + publicKey: KeyObject, +) { + const jwk = publicKey.export({ format: 'jwk' }) + + if (use) jwk['use'] = use + if (kid) jwk['kid'] = kid + if (alg) jwk['alg'] = alg + + return jwkPubSchema.parse(jwk) +} + +export class NodeKey extends Key { + static async fromImportable( + input: Importable, + kid: string, + ): Promise { + if (typeof input === 'string') { + // PKCS8 (string) + if (input.startsWith('-----')) { + return this.fromPKCS8(kid, input) + } + + // Jwk (string) + if (input.startsWith('{')) { + return this.fromJWK(input, kid) + } + + throw new TypeError('Invalid input') + } + + if (typeof input === 'object') { + // KeyObject + if (input instanceof KeyObject) { + return this.fromKeyObject(kid, input) + } + + // Jwk + if ( + !(input instanceof Uint8Array) && + ('kty' in input || 'alg' in input) + ) { + return this.fromJWK(input, kid) + } + + // KeyLike + return this.fromJWK(await exportJWK(input), kid) + } + + throw new TypeError('Invalid input') + } + + static async fromPKCS8( + kid: string, + pem: string, + use?: 'sig' | 'enc', + alg?: string, + ): Promise { + const privateKey = await importPKCS8(pem, '', { + extractable: true, + }) + + return this.fromKeyObject(kid, privateKey, use, alg) + } + + static async fromKeyObject( + kid: string, + privateKey: KeyObject, + inputUse?: 'sig' | 'enc', + inputAlg?: string, + ): Promise { + const jwk = jwkSchema.parse(privateKey.export({ format: 'jwk' })) + + const alg = either(jwk.alg, inputAlg) + const use = either(jwk.use, inputUse) || 'sig' + + const privateJwk = { ...jwk, use, kid, alg } + + if (privateKey.asymmetricKeyType) { + const publicKey = createPublicKey(privateKey) + const publicJwk = asPublicJwk(privateJwk, publicKey) + return new NodeKey({ privateJwk, privateKey, publicJwk, publicKey }) + } else { + return new NodeKey({ privateJwk, privateKey }) + } + } + + static async fromJWK( + input: string | Record, + inputKid?: string, + ): Promise { + const jwk = jwkSchema.parse( + typeof input === 'string' ? JSON.parse(input) : input, + ) + + const kid = either(jwk.kid, inputKid) + const alg = jwk.alg + const use = jwk.use || 'sig' + + // @ts-expect-error https://github.com/panva/jose/issues/634 + const privateKey = await importJWK(jwk) + if (!(privateKey instanceof KeyObject)) { + throw new TypeError('Expected an asymmetric key') + } + const privateJwk = { ...jwk, kid, alg, use } + + const publicKey = createPublicKey(privateKey) + const publicJwk = asPublicJwk(privateJwk, publicKey) + + return new NodeKey({ privateJwk, privateKey, publicJwk, publicKey }) + } +} diff --git a/packages/jwk-node/src/node-keypair.ts b/packages/jwk-node/src/node-keypair.ts deleted file mode 100644 index 3af4c2782e0..00000000000 --- a/packages/jwk-node/src/node-keypair.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { KeyObject, createPublicKey } from 'node:crypto' - -import { - Jwk, - Jwt, - JwtHeader, - JwtPayload, - Keypair, - jwkAlgorithms, - jwkPubSchema, - jwkSchema, -} from '@atproto/jwk' -import { CompactSign, importJWK, importPKCS8 } from 'jose' - -export type Importable = string | KeyObject | Record - -export class NodeKeypair implements Keypair { - static async fromImportable( - input: Importable, - kid: string, - ): Promise { - if (input instanceof KeyObject) { - return this.fromKeyObject(kid, input) - } - - if (typeof input === 'string' && input.startsWith('-----')) { - return this.fromPKCS8(kid, input) - } - - if (typeof input === 'string' && input.startsWith('{')) { - return this.fromJWK(input, kid) - } - - if (typeof input === 'object' && 'alg' in input) { - return this.fromJWK(input, kid) - } - - throw new TypeError(`Invalid key: ${kid}`) - } - - static async fromPKCS8( - kid: string, - pem: string, - use?: 'sig' | 'enc', - alg?: string, - ): Promise { - const privateKey = await importPKCS8(pem, '', { - extractable: true, - }) - - return this.fromKeyObject(kid, privateKey, use, alg) - } - - static async fromKeyObject( - kid: string, - privateKey: KeyObject, - use?: 'sig' | 'enc', - alg?: string, - ): Promise { - const publicKey = createPublicKey(privateKey) - - const jwk = jwkSchema.parse(privateKey.export({ format: 'jwk' })) - - if (jwk.alg) { - if (alg && jwk.alg !== alg) { - throw new TypeError(`Expected alg "${alg}", got "${jwk.alg}"`) - } - } - - if (jwk.use) { - if (use && jwk.use !== use) { - throw new TypeError(`Expected use "${use}", got "${jwk.use}"`) - } - } - - alg ||= jwk.alg - use ||= jwk.use || 'sig' - - return new NodeKeypair({ ...jwk, use, kid, alg }, privateKey, publicKey) - } - - static async fromJWK( - input: string | Record, - kid?: string, - ): Promise { - const jwk = jwkSchema.parse( - typeof input === 'string' ? JSON.parse(input) : input, - ) - - if (jwk.use) { - if (kid && jwk.use !== kid) { - throw new TypeError(`Expected kid "${kid}", got "${jwk.kid}"`) - } - } - - kid ||= jwk.kid - - // @ts-expect-error https://github.com/panva/jose/issues/634 - const privateKey = await importJWK(jwk) - if (!(privateKey instanceof KeyObject)) { - throw new TypeError('Expected an asymmetric key') - } - const publicKey = createPublicKey(privateKey) - return new NodeKeypair({ ...jwk, kid }, privateKey, publicKey) - } - - constructor( - public readonly privateJwk: Jwk, - /** Expose the privateKey so that the KeyObject can be used directly */ - public readonly privateKey: KeyObject, - public readonly publicKey: KeyObject, - ) { - if (!privateJwk.kid) - throw new TypeError('Missing "kid" (Key ID) Parameter value') - if (!privateJwk.use) - throw new TypeError('Missing "use" (Public Key Use) Parameter value') - } - - get publicJwk() { - const jwk = this.publicKey.export({ format: 'jwk' }) - const { kid, use, alg } = this - if (alg) jwk['alg'] = alg - if (use) jwk['use'] = use - if (kid) jwk['kid'] = kid - const publicJwk = jwkPubSchema.parse(jwk) // type safety + freezing - Object.defineProperty(this, 'publicJwk', { get: () => publicJwk }) - return publicJwk - } - - get kid() { - return this.privateJwk.kid! - } - - get alg() { - const { alg } = this.privateJwk - if (alg) return alg - - if (this.algorithms.size === 1) { - const [alg] = this.algorithms - return alg! - } - - return undefined - } - - get use() { - return this.privateJwk.use! - } - - get jwk() { - const { kty, crv, e, n, x, y } = this.publicJwk as any - return jwkSchema.parse({ crv, e, kty, n, x, y }) - } - - get algorithms(): ReadonlySet { - const algorithms = new Set(jwkAlgorithms(this.privateJwk)) - Object.defineProperty(this, 'algorithms', { get: () => algorithms }) - return algorithms - } - - sign(header: JwtHeader, payload: JwtPayload): Promise { - if (this.use !== 'sig') { - throw new TypeError('Not a signing key') - } - if (!this.algorithms.has(header.alg)) { - throw new TypeError('Unsupported alg') - } - - return new CompactSign(Buffer.from(JSON.stringify(payload))) - .setProtectedHeader(header) - .sign(this.privateKey) as Promise - } -} diff --git a/packages/jwk-node/src/node-keyset.ts b/packages/jwk-node/src/node-keyset.ts index 46ecba94c2c..2704a4735c9 100644 --- a/packages/jwk-node/src/node-keyset.ts +++ b/packages/jwk-node/src/node-keyset.ts @@ -1,12 +1,14 @@ -import { Keyset } from '@atproto/jwk' -import { Importable, NodeKeypair } from './node-keypair.js' +import { Key, Keyset } from '@atproto/jwk' +import { Importable, NodeKey } from './node-key.js' -export class NodeKeyset extends Keyset { - static async fromImportables(input: Record) { - return this.build( +export class NodeKeyset extends Keyset { + static async fromImportables( + input: Record, + ) { + return new NodeKeyset( await Promise.all( Object.entries(input).map(([kid, secret]) => - NodeKeypair.fromImportable(secret, kid), + secret instanceof Key ? secret : NodeKey.fromImportable(secret, kid), ), ), ) diff --git a/packages/jwk/package.json b/packages/jwk/package.json index 283d83dc20f..49d221a3bbf 100644 --- a/packages/jwk/package.json +++ b/packages/jwk/package.json @@ -26,6 +26,7 @@ } }, "dependencies": { + "jose": "^5.2.2", "tslib": "^2.6.2", "zod": "^3.22.4" }, diff --git a/packages/jwk/src/alg.ts b/packages/jwk/src/alg.ts new file mode 100644 index 00000000000..226a1bb6624 --- /dev/null +++ b/packages/jwk/src/alg.ts @@ -0,0 +1,97 @@ +import { Jwk } from './jwk.js' + +declare const process: undefined | { versions?: { node?: string } } +const IS_NODE_RUNTIME = + typeof process !== 'undefined' && typeof process?.versions?.node === 'string' + +export function* jwkAlgorithms(jwk: Jwk): Generator { + // Ed25519, Ed448, and secp256k1 always have "alg" + // OKP always has "use" + if (jwk.alg) { + yield jwk.alg + return + } + + switch (jwk.kty) { + case 'EC': { + if (jwk.use === 'enc' || jwk.use === undefined) { + yield 'ECDH-ES' + yield 'ECDH-ES+A128KW' + yield 'ECDH-ES+A192KW' + yield 'ECDH-ES+A256KW' + } + + if (jwk.use === 'sig' || jwk.use === undefined) { + const crv = 'crv' in jwk ? jwk.crv : undefined + switch (crv) { + case 'P-256': + case 'P-384': + yield `ES${crv.slice(-3)}`.replace('21', '12') + break + case 'P-521': + yield 'ES512' + break + case 'secp256k1': + if (IS_NODE_RUNTIME) yield 'ES256K' + break + default: + throw new TypeError(`Unsupported crv "${crv}"`) + } + } + + return + } + + case 'OKP': { + if (!jwk.use) throw new TypeError('Missing "use" Parameter value') + yield 'ECDH-ES' + yield 'ECDH-ES+A128KW' + yield 'ECDH-ES+A192KW' + yield 'ECDH-ES+A256KW' + return + } + + case 'RSA': { + if (jwk.use === 'enc' || jwk.use === undefined) { + yield 'RSA-OAEP' + yield 'RSA-OAEP-256' + yield 'RSA-OAEP-384' + yield 'RSA-OAEP-512' + if (IS_NODE_RUNTIME) yield 'RSA1_5' + } + + if (jwk.use === 'sig' || jwk.use === undefined) { + yield 'PS256' + yield 'PS384' + yield 'PS512' + yield 'RS256' + yield 'RS384' + yield 'RS512' + } + + return + } + + case 'oct': { + if (jwk.use === 'enc' || jwk.use === undefined) { + yield 'A128GCMKW' + yield 'A192GCMKW' + yield 'A256GCMKW' + yield 'A128KW' + yield 'A192KW' + yield 'A256KW' + } + + if (jwk.use === 'sig' || jwk.use === undefined) { + yield 'HS256' + yield 'HS384' + yield 'HS512' + } + + return + } + + default: + throw new Error(`Unsupported kty "${jwk.kty}"`) + } +} diff --git a/packages/jwk/src/index.ts b/packages/jwk/src/index.ts index cb6ee998ce1..1e86d1ad21f 100644 --- a/packages/jwk/src/index.ts +++ b/packages/jwk/src/index.ts @@ -1,7 +1,8 @@ +export * from './alg.js' export * from './jwk.js' export * from './jwks.js' export * from './jwt.js' -export * from './keypair.js' +export * from './key.js' export * from './keyset.js' export * from './types.js' export * from './util.js' diff --git a/packages/jwk/src/key.ts b/packages/jwk/src/key.ts new file mode 100644 index 00000000000..1a6e53c9d0b --- /dev/null +++ b/packages/jwk/src/key.ts @@ -0,0 +1,98 @@ +import { jwkAlgorithms } from './alg.js' +import { Jwk, jwkSchema } from './jwk.js' +import { KeyLike } from './types.js' +import { cachedGetter, either } from './util.js' + +export class Key { + readonly privateJwk?: Jwk + readonly privateKey?: KeyLike + + readonly publicJwk?: Jwk + readonly publicKey?: KeyLike + + constructor({ + privateJwk, + privateKey, + publicJwk, + publicKey, + }: + | { + privateJwk: Jwk + privateKey?: KeyLike + publicJwk?: Jwk + publicKey?: KeyLike + } + | { + privateJwk?: Jwk + privateKey?: KeyLike + publicJwk: Jwk + publicKey?: KeyLike + }) { + if (!privateJwk && !publicJwk) + throw new TypeError('At least one of privateJwk or publicJwk is required') + if (privateKey && !privateJwk) + throw new TypeError('privateKey must be used with privateJwk') + if (publicKey && !publicJwk) + throw new TypeError('publicKey must be used with publicJwk') + + this.privateJwk = privateJwk + this.privateKey = privateKey + + this.publicJwk = publicJwk + this.publicKey = publicKey + } + + /** + * A key should always be used either for signing or encryption. + */ + get use() { + const use = either(this.privateJwk?.use, this.publicJwk?.use) + if (!use) throw new TypeError('Missing "use" Parameter value') + return use + } + + /** + * The (forced) algorithm to use. If not provided, the key will be usable with + * any of the algorithms in {@link algorithms}. + */ + get alg() { + return either(this.privateJwk?.alg, this.publicJwk?.alg) + } + + /** + * The key ID. + */ + get kid() { + const kid = either(this.privateJwk?.kid, this.publicJwk?.kid) + if (!kid) throw new TypeError('Missing "kid" Parameter value') + return kid + } + + get canVerify() { + return this.use === 'sig' + } + + get canSign() { + return this.use === 'sig' && this.privateJwk != null + } + + /** + * The "bare" public jwk (without `kid`, `use` and `alg`), to use inside a + * "cnf" JWT header. + */ + @cachedGetter + get bareJwk(): Jwk { + const { kty, crv, e, n, x, y } = (this.publicJwk || this.privateJwk) as any + return jwkSchema.parse({ crv, e, kty, n, x, y }) + } + + /** + * All the algorithms that this key can be used with. If `alg` is provided, + * this set will only contain that algorithm. + */ + @cachedGetter + get algorithms(): readonly string[] { + const jwk = this.privateJwk || this.publicJwk + return Array.from(jwk ? jwkAlgorithms(jwk) : []) + } +} diff --git a/packages/jwk/src/keypair.ts b/packages/jwk/src/keypair.ts deleted file mode 100644 index 1b82c5c6c8d..00000000000 --- a/packages/jwk/src/keypair.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Jwk } from './jwk.js' -import { Jwt, JwtHeader, JwtPayload } from './jwt.js' - -export interface Keypair { - /** - * The key ID. - */ - readonly kid: string - - /** - * A keypair should always be used either for signing or encryption. - */ - readonly use: 'sig' | 'enc' - - /** - * The (forced) algorithm to use. If not provided, the keypair will be usable - * with any of the algorithms in `algorithms`. - */ - readonly alg?: string - - /** - * The "bare" public jwk (without `kid`, `use` and `alg`), to use - * as header claim. - */ - readonly jwk: Jwk - - /** - * All the algorithms that this keypair can be used with. If `alg` is - * provided, this set will only contain that algorithm. - */ - readonly algorithms: ReadonlySet - - readonly privateJwk: Jwk - readonly publicJwk: Jwk - - sign(header: JwtHeader, payload: JwtPayload): Promise -} diff --git a/packages/jwk/src/keyset.ts b/packages/jwk/src/keyset.ts index f707f60d2f2..d8417ec068e 100644 --- a/packages/jwk/src/keyset.ts +++ b/packages/jwk/src/keyset.ts @@ -1,90 +1,201 @@ +import { JWK, JWTVerifyOptions, SignJWT, importJWK, jwtVerify } from 'jose' + +import { Jwk } from './jwk.js' import { Jwks } from './jwks.js' -import { Keypair } from './keypair.js' - -export class Keyset implements Iterable { - static build( - inputKeys: Iterable, - defaultKeyId?: string, - ): Keyset { - const keys = new Map() - - for (const key of inputKeys) { - defaultKeyId ??= key.kid - if (keys.has(key.kid)) throw new Error(`Duplicate key id: ${key.kid}`) - else keys.set(key.kid, key) - } +import { Jwt, JwtHeader, JwtPayload } from './jwt.js' +import { Key } from './key.js' +import { + cachedGetter, + isDefined, + matchesAny, + preferredOrderCmp, +} from './util.js' + +export type { JWTVerifyOptions } - if (!keys.size) throw new Error('Keyset is empty') - if (!defaultKeyId) throw new Error('Default key not found') - if (!keys.has(defaultKeyId)) throw new Error('Default key not found') +export type JwtSignHeader = Pick & + Omit - return new Keyset(defaultKeyId, keys) +export type JwtVerifyResult

= { + payload: P & JwtPayload + protectedHeader: { + alg: string + b64?: boolean + crit?: string[] + [propName: string]: unknown } +} + +export type KeySearch = { + use?: 'sig' | 'enc' + kid?: string + alg?: string | string[] +} + +const extractPrivateJwk = (key: Key): Jwk | undefined => key.privateJwk +const extractPublicJwk = (key: Key): Jwk | undefined => key.publicJwk +export class Keyset implements Iterable { constructor( - public readonly defaultKeyId: string, - private readonly keys: ReadonlyMap, - ) {} + private readonly keys: readonly K[], + /** + * The preferred algorithms to use when signing a JWT using this keyset. + */ + readonly preferredSigningAlgorithms: readonly string[] = [ + 'EdDSA', + 'ES256K', + 'ES256', + // https://datatracker.ietf.org/doc/html/rfc7518#section-3.5 + 'PS256', + 'PS384', + 'PS512', + 'HS256', + 'HS384', + 'HS512', + ], + ) { + if (!keys.length) throw new Error('Keyset is empty') + + const kids = new Set() + for (const key of keys) { + if (kids.has(key.kid)) throw new Error(`Duplicate key id: ${key.kid}`) + else kids.add(key.kid) + } + } - get signAlgorithms(): ReadonlySet { + @cachedGetter + get signAlgorithms(): readonly string[] { const algorithms = new Set() - for (const key of this.keys.values()) { + for (const key of this) { if (key.use !== 'sig') continue for (const alg of key.algorithms) { algorithms.add(alg) } } - Object.defineProperty(this, 'signAlgorithms', { get: () => algorithms }) - return algorithms + return Object.freeze( + [...algorithms].sort(preferredOrderCmp(this.preferredSigningAlgorithms)), + ) } - exportJwks({ privateKey = false } = {}): Jwks { + @cachedGetter + get publicJwks(): Jwks { return { - keys: Array.from(this.keys.values(), (key) => - privateKey ? key.privateJwk : key.publicJwk, - ), + keys: Array.from(this, extractPublicJwk).filter(isDefined), + } + } + + @cachedGetter + get privateJwks(): Jwks { + return { + keys: Array.from(this, extractPrivateJwk).filter(isDefined), } } has(kid: string): boolean { - return this.keys.has(kid) + return this.keys.some((key) => key.kid === kid) + } + + get(search: KeySearch): K { + for (const key of this.list(search)) { + return key + } + + throw new TypeError( + `Key not found ${search.kid || search.alg || ''}`, + ) } - get(search: { - use?: 'sig' | 'enc' - kid?: string - alg?: string | string[] - }): K { - for (const key of this.keys.values()) { + *list(search: KeySearch): Generator { + for (const key of this) { if (search.kid && key.kid !== search.kid) continue if (search.use && key.use !== search.use) continue if (Array.isArray(search.alg)) { - if (!search.alg.some((a) => key.algorithms.has(a))) continue + if (!search.alg.some((a) => key.algorithms.includes(a))) continue } else if (typeof search.alg === 'string') { - if (!key.algorithms.has(search.alg)) continue + if (!key.algorithms.includes(search.alg)) continue } - return key + yield key } - - throw new Error('No key found') } - *map(transform: (key: K) => T): IterableIterator { - for (const key of this.keys.values()) yield transform(key) - } + findSigningKey(search: Omit): [key: Key, alg: string] { + const { kid, alg } = search + const matchingKeys: Key[] = [] + + for (const key of this.list({ kid, alg, use: 'sig' })) { + // Not a signing key + if (!key.canSign) continue + + // Skip negotiation if a specific "alg" was provided + if (typeof alg === 'string') return [key, alg] + + matchingKeys.push(key) + } + + const isAllowedAlg = matchesAny(alg) + const candidates = matchingKeys.map( + (key) => [key, key.algorithms.filter(isAllowedAlg)] as const, + ) - *filter(predicate: (key: K) => boolean): IterableIterator { - for (const key of this.keys.values()) { - if (predicate(key)) yield key + // Return the first candidates that matches the preferred algorithms + for (const prefAlg of this.preferredSigningAlgorithms) { + for (const [matchingKey, matchingAlgs] of candidates) { + if (matchingAlgs.includes(prefAlg)) return [matchingKey, prefAlg] + } } + + // Return any candidate + for (const [matchingKey, matchingAlgs] of candidates) { + for (const alg of matchingAlgs) { + return [matchingKey, alg] + } + } + + throw new TypeError(`No singing key found for ${kid || alg || ''}`) } - *flatMap(transform: (key: K) => Iterable): Iterable { - for (const key of this.keys.values()) yield* transform(key) + [Symbol.iterator](): IterableIterator { + return this.keys.values() } - *[Symbol.iterator](): IterableIterator { - yield* this.keys.values() + async sign(header: JwtSignHeader, payload: JwtPayload): Promise { + const [key, alg] = this.findSigningKey({ alg: header.alg, kid: header.kid }) + + const keyObj = key.privateKey || (await importJWK(key.privateJwk! as JWK)) + + return new SignJWT(payload) + .setProtectedHeader({ ...header, alg, kid: key.kid }) + .sign(keyObj) as Promise + } + + async verify

( + token: Jwt, + options?: JWTVerifyOptions, + ): Promise> { + return jwtVerify

( + token, + async (header) => { + const key = this.get({ + use: 'sig', + kid: header.kid, + alg: header.alg, + }) + + const keyLike = + key.publicKey || + key.privateKey || + (key.privateJwk + ? await importJWK(key.privateJwk as JWK) + : undefined) || + (await importJWK(key.publicJwk! as JWK)) + + // Should never happen because the Key constructor enforces this + if (!keyLike) throw new Error('Invalid key') + + return keyLike + }, + options, + ) } } diff --git a/packages/jwk/src/types.ts b/packages/jwk/src/types.ts index e94974d0d6e..0adea8a8198 100644 --- a/packages/jwk/src/types.ts +++ b/packages/jwk/src/types.ts @@ -17,3 +17,6 @@ export type JWSAlgorithm = | 'ES512' // OKP | 'EdDSA' + +// Runtime specific key representation or secret +export type KeyLike = { type: string } | Uint8Array diff --git a/packages/jwk/src/util.ts b/packages/jwk/src/util.ts index 1dc4f43a747..4ae5378abdd 100644 --- a/packages/jwk/src/util.ts +++ b/packages/jwk/src/util.ts @@ -1,171 +1,50 @@ -import { Jwk, KeyUsage } from './jwk.js' -import { JWSAlgorithm } from './types.js' - -/** - * @todo Ensure that "key_ops" and "use" are consistent when both are present - */ -export function jwkKeyUsage(jwk: Jwk): readonly KeyUsage[] { - if (jwk.key_ops) return jwk.key_ops - - return jwk?.use === 'enc' - ? 'd' in jwk - ? ['decrypt', 'unwrapKey', 'encrypt', 'wrapKey'] - : ['encrypt', 'wrapKey'] - : 'd' in jwk - ? ['sign', 'verify'] - : ['verify'] -} - -export function algToKty(alg: string) { - switch (alg.slice(0, 2)) { - case 'RS': - case 'PS': - return 'RSA' - case 'ES': - return 'EC' - case 'Ed': - return 'OKP' - default: - return undefined +export const isDefined = (i: T | undefined): i is T => i !== undefined + +export const preferredOrderCmp = + (order: readonly T[]) => + (a: T, b: T) => { + const aIdx = order.indexOf(a) + const bIdx = order.indexOf(b) + if (aIdx === bIdx) return 0 + if (aIdx === -1) return 1 + if (bIdx === -1) return -1 + return aIdx - bIdx } -} -export function asAlg< - A extends string, - K extends readonly string[] | undefined = undefined, ->(alg: A, supportedAlg?: K): K extends readonly (infer U)[] ? U & A : A -export function asAlg(alg: string, supportedAlg?: readonly string[]): string { - if (!supportedAlg || supportedAlg.includes(alg)) return alg - throw new TypeError( - `Unsupported alg "${alg}" (only ${supportedAlg} are supported)`, - ) +export function matchesAny( + value: null | undefined | T | readonly T[], +): (v: unknown) => v is T { + return value == null + ? (v): v is T => true + : Array.isArray(value) + ? (v): v is T => value.includes(v) + : (v): v is T => v === value } -export function jwkAlg( - jwk: Jwk, - supportedAlg?: readonly string[], -): JWSAlgorithm { - const alg = 'alg' in jwk ? jwk.alg : undefined - if (alg) return asAlg(alg as JWSAlgorithm, supportedAlg) - - switch (jwk.kty) { - case 'OKP': { - if (!('crv' in jwk)) throw new TypeError('Missing "crv" Parameter value') - switch (jwk.crv) { - case 'Ed25519': - case 'Ed448': - return asAlg('EdDSA', supportedAlg) - } - // @ts-expect-error - throw new TypeError(`Unsupported crv "${jwk.crv}"`) - } - case 'EC': { - if (!('crv' in jwk)) throw new TypeError('Missing "crv" Parameter value') - switch (jwk.crv) { - case 'secp256k1': - return asAlg('ES256K', supportedAlg) - case 'P-256': - case 'P-384': - return asAlg(`ES${jwk.crv.slice(2) as '256' | '384'}`, supportedAlg) - case 'P-521': - return asAlg('ES512', supportedAlg) - } - // @ts-expect-error - throw new TypeError(`Unsupported crv "${jwk.crv}"`) - } - case 'RSA': { - if (!('n' in jwk)) throw new TypeError('Missing "n" Parameter value') - const len = jwk.n.length * 8 - switch (len) { - case 256: - case 384: - case 512: - for (const fam of ['PS', 'RS'] as const) { - try { - return asAlg(`${fam}${len}`, supportedAlg) - } catch { - continue - } - } - } - throw new TypeError(`Unsupported alg "PS${len}" or "RS${len}"`) - } - default: - throw new Error(`Unsupported key type: ${jwk.kty}`) +/** + * Decorator to cache the result of a getter on a class instance. + */ +export const cachedGetter = ( + target: (this: T) => V, + _context: ClassGetterDecoratorContext, +) => { + return function (this: T) { + const value = target.call(this) + Object.defineProperty(this, target.name, { + get: () => value, + enumerable: true, + configurable: true, + }) + return value } } -declare const process: undefined | { versions?: { node?: string } } -const IS_NODE_RUNTIME = - typeof process !== 'undefined' && typeof process?.versions?.node === 'string' -export function* jwkAlgorithms(jwk: Jwk): Generator { - // Ed25519, Ed448, and secp256k1 always have "alg" - // OKP always has "use" - if (jwk.alg) { - yield jwk.alg - return - } - - switch (jwk.kty) { - case 'EC': { - if (jwk.use === 'enc' || jwk.use === undefined) { - yield 'ECDH-ES' - yield 'ECDH-ES+A128KW' - yield 'ECDH-ES+A192KW' - yield 'ECDH-ES+A256KW' - } - - if (jwk.use === 'sig' || jwk.use === undefined) { - const crv = 'crv' in jwk ? jwk.crv : undefined - switch (crv) { - case 'P-256': - case 'P-384': - yield `ES${crv.slice(-3)}`.replace('21', '12') - break - case 'P-521': - yield 'ES512' - break - case 'secp256k1': - if (IS_NODE_RUNTIME) yield 'ES256K' - break - default: - throw new TypeError(`Unsupported crv "${crv}"`) - } - } - - return - } - - case 'OKP': { - yield 'ECDH-ES' - yield 'ECDH-ES+A128KW' - yield 'ECDH-ES+A192KW' - yield 'ECDH-ES+A256KW' - return - } - - case 'RSA': { - if (jwk.use === 'enc' || jwk.use === undefined) { - yield 'RSA-OAEP' - yield 'RSA-OAEP-256' - yield 'RSA-OAEP-384' - yield 'RSA-OAEP-512' - if (IS_NODE_RUNTIME) yield 'RSA1_5' - } - - if (jwk.use === 'sig' || jwk.use === undefined) { - yield 'PS256' - yield 'PS384' - yield 'PS512' - yield 'RS256' - yield 'RS384' - yield 'RS512' - } - - return - } - - default: - throw new Error('unreachable') +export function either( + a?: T, + b?: T, +): T | undefined { + if (a != null && b != null && a !== b) { + throw new TypeError(`Expected "${b}", got "${a}"`) } + return a ?? b ?? undefined } diff --git a/packages/oauth-provider/src/client/client-manager.ts b/packages/oauth-provider/src/client/client-manager.ts index 13dfc606cfe..af9042b7746 100644 --- a/packages/oauth-provider/src/client/client-manager.ts +++ b/packages/oauth-provider/src/client/client-manager.ts @@ -104,7 +104,9 @@ export class ClientManager { if ( metadata.userinfo_signed_response_alg && - !this.keyset.signAlgorithms.has(metadata.userinfo_signed_response_alg) + !this.keyset.signAlgorithms.includes( + metadata.userinfo_signed_response_alg, + ) ) { throw new InvalidClientMetadataError( `Unsupported "userinfo_signed_response_alg" ${metadata.userinfo_signed_response_alg}`, @@ -113,7 +115,9 @@ export class ClientManager { if ( metadata.id_token_signed_response_alg && - !this.keyset.signAlgorithms.has(metadata.id_token_signed_response_alg) + !this.keyset.signAlgorithms.includes( + metadata.id_token_signed_response_alg, + ) ) { throw new InvalidClientMetadataError( `Unsupported "id_token_signed_response_alg" ${metadata.id_token_signed_response_alg}`, diff --git a/packages/oauth-provider/src/constants.ts b/packages/oauth-provider/src/constants.ts index 6e9ba220e78..03564ba6093 100644 --- a/packages/oauth-provider/src/constants.ts +++ b/packages/oauth-provider/src/constants.ts @@ -24,13 +24,32 @@ const DAY = 24 * HOUR const YEAR = 365.25 * DAY const MONTH = YEAR / 12 +/** 7 days */ export const AUTH_MAX_AGE = 7 * DAY + +/** 60 minutes */ export const TOKEN_MAX_AGE = 60 * MINUTE + +/** 5 minutes */ export const AUTHORIZATION_INACTIVITY_TIMEOUT = 5 * MINUTE + +/** 1 months */ export const AUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT = 1 * MONTH + +/** 2 days */ export const UNAUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT = 2 * DAY -export const TOTAL_REFRESH_LIFETIME = 365 * DAY + +/** 1 year */ +export const TOTAL_REFRESH_LIFETIME = 1 * YEAR + +/** 5 minutes */ export const PAR_EXPIRES_IN = 5 * MINUTE -export const JAR_MAX_AGE = MINUTE + +/** 1 minute */ +export const JAR_MAX_AGE = 1 * MINUTE + +/** 1 minute */ export const CLIENT_ASSERTION_MAX_AGE = 1 * MINUTE + +/** 3 minutes */ export const DPOP_NONCE_MAX_AGE = 3 * MINUTE diff --git a/packages/oauth-provider/src/dpop/dpop-provider.ts b/packages/oauth-provider/src/dpop/dpop-provider.ts new file mode 100644 index 00000000000..550ba3655dd --- /dev/null +++ b/packages/oauth-provider/src/dpop/dpop-provider.ts @@ -0,0 +1,54 @@ +import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' +import { ReplayManager } from '../replay/replay-manager.js' +import { ReplayStore } from '../replay/replay-store.js' +import { DpopManager } from './dpop-manager.js' +import { DpopNonce } from './dpop-nonce.js' + +export type DpopProviderOptions = { + replayStore: ReplayStore + + /** + * Set this to `false` to disable the use of DPoP nonces. Set this to a secret + * Uint8Array to use a predictable seed for all nonces (typically useful when + * multiple instances are running). Leave undefined to generate a random seed + * at startup. + */ + dpopNonce?: false | Uint8Array | DpopNonce +} + +export class DpopProvider { + public readonly replayManager: ReplayManager + public readonly dpopManager: DpopManager + + constructor({ + dpopNonce = new DpopNonce(), + replayStore, + }: DpopProviderOptions) { + this.replayManager = new ReplayManager(replayStore) + this.dpopManager = new DpopManager( + dpopNonce instanceof Uint8Array + ? new DpopNonce(dpopNonce) + : dpopNonce || undefined, + ) + } + + public nextDpopNonce() { + return this.dpopManager.nextNonce() + } + + public async dpopCheck( + proof: unknown, + htm: string, + htu: string, + accessToken?: string, + ): Promise { + if (proof === undefined) return null + + const result = await this.dpopManager.verify(proof, htm, htu, accessToken) + + const unique = await this.replayManager.uniqueDpop(result.jti) + if (!unique) throw new InvalidDpopProofError('DPoP proof jti is not unique') + + return result.jkt + } +} diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index fd2271de29a..73b2b1ae7c2 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -1,5 +1,6 @@ export * from './constants.js' export * from './oauth-client.js' +export * from './oauth-dpop.js' export * from './oauth-errors.js' export * from './oauth-hooks.js' export * from './oauth-provider.js' diff --git a/packages/oauth-provider/src/oauth-dpop.ts b/packages/oauth-provider/src/oauth-dpop.ts new file mode 100644 index 00000000000..685620f51ae --- /dev/null +++ b/packages/oauth-provider/src/oauth-dpop.ts @@ -0,0 +1,3 @@ +export * from './dpop/dpop-nonce.js' +export * from './dpop/dpop-manager.js' +export * from './dpop/dpop-provider.js' diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 461ab23c371..e8aaa1d71c8 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -39,12 +39,10 @@ import { ClientStore, asClientStore } from './client/client-store.js' import { Client } from './client/client.js' import { AUTH_MAX_AGE } from './constants.js' import { DeviceId } from './device/device-id.js' -import { DpopManager } from './dpop/dpop-manager.js' -import { DpopNonce } from './dpop/dpop-nonce.js' +import { DpopProvider, DpopProviderOptions } from './dpop/dpop-provider.js' import { AccessDeniedError } from './errors/access-denied-error.js' import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js' import { ConsentRequiredError } from './errors/consent-required-error.js' -import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js' import { InvalidRequestError } from './errors/invalid-request-error.js' import { LoginRequiredError } from './errors/login-required-error.js' import { OAuthError } from './errors/oauth-error.js' @@ -75,7 +73,6 @@ import { authorizationParametersSchema, } from './parameters/authorization-parameters.js' import { oidcPayload } from './parameters/oidc-payload.js' -import { ReplayManager } from './replay/replay-manager.js' import { ReplayStore, asReplayStore } from './replay/replay-store.js' import { AuthorizationRequestHook, @@ -118,7 +115,9 @@ import { keyJwkThumbprint } from './util/crypto.js' import { dateToEpoch, dateToRelativeSeconds } from './util/date.js' import { formatWWWAuthenticateHeader } from './util/www-authenticate.js' -export type { Handler } +export { Keyset } + +export type { CustomMetadata, Handler } export type OAuthProviderStore = Partial< ClientStore & @@ -129,7 +128,7 @@ export type OAuthProviderStore = Partial< ReplayStore > -export type OAuthProviderOptions = { +export type OAuthProviderOptions = DpopProviderOptions & { /** * The "issuer" identifier of the OAuth provider, this is the base URL of the * OAuth provider. @@ -150,14 +149,6 @@ export type OAuthProviderOptions = { */ defaultMaxAge?: number - /** - * Set this to `false` to disable the use of DPoP nonces. Set this to a secret - * Uint8Array to use a predictable seed for all nonces (typically useful when - * multiple instances are running). Leave undefined to generate a random seed - * at startup. - */ - dpopNonce?: false | Uint8Array | DpopNonce - /** * Additional metadata to be included in the discovery document. */ @@ -185,29 +176,27 @@ export type OAuthProviderOptions = { store?: OAuthProviderStore } -export class OAuthProvider { +export class OAuthProvider extends DpopProvider { public readonly metadata: Metadata - public readonly jwks: Jwks - private readonly defaultMaxAge: number - private readonly issuer: string + public readonly defaultMaxAge: number + public readonly issuer: string - private readonly signer: Signer - private readonly sessionStore: SessionStore + public readonly keyset: Keyset + public readonly signer: Signer + public readonly sessionStore: SessionStore - private readonly accountManager: AccountManager - private readonly clientManager: ClientManager - private readonly dpopManager: DpopManager - private readonly replayManager: ReplayManager - private readonly requestManager: RequestManager - private readonly tokenManager: TokenManager + public readonly accountManager: AccountManager + public readonly clientManager: ClientManager + public readonly requestManager: RequestManager + public readonly tokenManager: TokenManager public constructor({ issuer, keyset, store, defaultMaxAge = AUTH_MAX_AGE, - dpopNonce = new DpopNonce(), + dpopNonce, metadata, onAuthorizationRequest, @@ -237,28 +226,24 @@ export class OAuthProvider { onTokenResponse, } + super({ dpopNonce, replayStore }) + this.defaultMaxAge = defaultMaxAge this.issuer = issuerUrl.href this.metadata = buildMetadata(issuerUrl.href, keyset, metadata) - this.jwks = keyset.exportJwks() + this.keyset = keyset this.signer = signer this.sessionStore = sessionStore this.accountManager = new AccountManager(accountStore) this.clientManager = new ClientManager(clientStore, keyset, hooks) - this.dpopManager = new DpopManager( - dpopNonce instanceof Uint8Array - ? new DpopNonce(dpopNonce) - : dpopNonce || undefined, - ) - this.replayManager = new ReplayManager(replayStore) this.requestManager = new RequestManager(requestStore, signer, hooks) this.tokenManager = new TokenManager(tokenStore, signer, hooks) } - public nextDpopNonce() { - return this.dpopManager.nextNonce() + get jwks(): Jwks { + return this.keyset.publicJwks } protected loginRequired( @@ -765,22 +750,6 @@ export class OAuthProvider { ) } - public async dpopCheck( - proof: unknown, - htm: string, - htu: string, - accessToken?: string, - ): Promise { - if (proof === undefined) return null - - const result = await this.dpopManager.verify(proof, htm, htu, accessToken) - - const unique = await this.replayManager.uniqueDpop(result.jti) - if (!unique) throw new InvalidDpopProofError('DPoP proof jti is not unique') - - return result.jkt - } - /** * @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 rfc7009} */ diff --git a/packages/oauth-provider/src/signer/signer.ts b/packages/oauth-provider/src/signer/signer.ts index 63518d3c90c..633e44ac32f 100644 --- a/packages/oauth-provider/src/signer/signer.ts +++ b/packages/oauth-provider/src/signer/signer.ts @@ -1,17 +1,14 @@ import { randomBytes } from 'node:crypto' import { - JWSAlgorithm, + JWTVerifyOptions, Jwt, JwtHeader, JwtPayload, - Keypair, + JwtSignHeader, Keyset, - jwkAlg, jwtPayloadSchema, } from '@atproto/jwk' -import { NodeKeypair } from '@atproto/jwk-node' -import { JWTVerifyOptions, importJWK, jwtVerify } from 'jose' import { generate as hash } from 'oidc-token-hash' import { Account } from '../account/account.js' @@ -25,39 +22,28 @@ import { AccessToken } from '../token/access-token.js' import { TokenId, tokenIdSchema } from '../token/token-id.js' import { dateToEpoch } from '../util/date.js' -const PREFERRED_ALGORITHMS: readonly JWSAlgorithm[] = [ - 'EdDSA', - 'ES256K', - 'ES256', - 'ES384', - 'ES512', - 'PS256', - 'PS384', - 'PS512', - // Prefer RSASSA-PSS (https://datatracker.ietf.org/doc/html/rfc7518#section-3.5) - // 'RS256', - // 'RS384', - // 'RS512', -] +export type AccessTokenPayload = { + jti: TokenId + exp: number + iat: number + iss: string + aud: string + sub: string + client_id: string +} export class Signer { constructor(public readonly issuer: string, public readonly keyset: Keyset) {} - public keyAlg(requestedAlg?: string): [key: Keypair, alg: string] { - const key = this.keyset.get({ use: 'sig', alg: requestedAlg }) - const alg = requestedAlg || jwkAlg(key.publicJwk, PREFERRED_ALGORITHMS) - return [key, alg] + async verify

(token: Jwt, options?: Omit) { + return this.keyset.verify

(token, { ...options, issuer: this.issuer }) } - public sign( - header: Partial, - payload: Partial, + public async sign( + header: JwtSignHeader, + payload: Omit, ): Promise { - const [key, alg] = this.keyAlg(header.alg) - return key.sign( - { ...header, alg, kid: key.kid }, - { ...payload, iss: this.issuer }, - ) + return this.keyset.sign(header, { ...payload, iss: this.issuer }) } async accessToken( @@ -95,15 +81,7 @@ export class Signer { } async verifyAccessToken(token: Jwt) { - const result = await this.verify<{ - jti: TokenId // Will be validated hereafter - exp: number - iat: number - iss: string - aud: string - sub: string - client_id: string - }>(token, { + const result = await this.verify(token, { typ: 'at+jwt', requiredClaims: ['jti', 'exp', 'iat', 'iss', 'aud', 'sub', 'client_id'], }) @@ -135,7 +113,18 @@ export class Signer { throw new InvalidRequestError('Missing required "nonce" parameter') } - const [key, alg] = this.keyAlg(client.metadata.id_token_signed_response_alg) + const [key, alg] = (() => { + try { + return this.keyset.findSigningKey({ + alg: client.metadata.id_token_signed_response_alg, + }) + } catch (err) { + throw new InvalidRequestError( + `Unsupported id_token_signed_response_alg "${client.metadata.id_token_signed_response_alg}"`, + err, + ) + } + })() const header: JwtHeader = { alg, @@ -173,27 +162,6 @@ export class Signer { : undefined, } - return key.sign(header, payload) - } - - async verify( - token: Jwt, - options?: Omit, - ) { - return jwtVerify( - token, - async (header, _token) => { - const key = this.keyset.get({ - use: 'sig', - kid: header.kid, - alg: header.alg, - }) - return key instanceof NodeKeypair - ? key.publicKey - : // @ts-expect-error https://github.com/panva/jose/issues/634 - importJWK(key.publicJwk) - }, - { ...options, issuer: this.issuer }, - ) + return this.sign(header, payload) } } diff --git a/packages/pds/example.env b/packages/pds/example.env index fc3c3520eb0..d314e170af7 100644 --- a/packages/pds/example.env +++ b/packages/pds/example.env @@ -14,6 +14,7 @@ PDS_REPO_SIGNING_KEY_K256_PRIVATE_KEY_HEX="3ee68..." PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX="e049f..." # Secrets - update to secure high-entropy strings +PDS_DPOP_SECRET="32-random-bytes-hex-encoded" PDS_JWT_SECRET="jwt-secret" PDS_ADMIN_PASSWORD="admin-pass" @@ -21,4 +22,4 @@ PDS_ADMIN_PASSWORD="admin-pass" PDS_DID_PLC_URL="https://plc.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_ENDPOINT="https://api.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_DID="did:web:api.bsky-sandbox.dev" -PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" \ No newline at end of file +PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" diff --git a/packages/pds/src/account-manager/helpers/used-refresh-token.ts b/packages/pds/src/account-manager/helpers/used-refresh-token.ts index 518ee2aed5f..bf5ff896005 100644 --- a/packages/pds/src/account-manager/helpers/used-refresh-token.ts +++ b/packages/pds/src/account-manager/helpers/used-refresh-token.ts @@ -6,11 +6,12 @@ export const insert = async ( id: number, usedRefreshToken: RefreshToken, ) => { - await db.db - .insertInto('used_refresh_token') - .values({ id, usedRefreshToken }) - .onConflict((oc) => oc.columns(['id', 'usedRefreshToken']).doNothing()) - .execute() + await db.executeWithRetry( + db.db + .insertInto('used_refresh_token') + .values({ id, usedRefreshToken }) + .onConflict((oc) => oc.columns(['id', 'usedRefreshToken']).doNothing()), + ) } export const findByToken = (db: AccountDb, usedRefreshToken: RefreshToken) => { diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 560e84b780e..8b00f1cd13b 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -51,7 +51,6 @@ export class AccountManager dbLocation: string, private jwtKey: KeyObject, private serviceDid: string, - readonly publicUrl: string, disableWalAutoCheckpoint = false, ) { this.db = getDb(dbLocation, disableWalAutoCheckpoint) diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 554898dbbaf..211a9197ea0 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -9,24 +9,31 @@ export const resultPassthru = (result: { headers: Headers; data: T }) => { } } +export type AuthHeaders = { + authorization: string + dpop?: string +} + // Output designed to passed as second arg to AtpAgent methods. // The encoding field here is a quirk of the AtpAgent. export function authPassthru( req: IncomingMessage, withEncoding?: false, -): { headers: { authorization: string }; encoding: undefined } | undefined +): { headers: AuthHeaders; encoding: undefined } | undefined export function authPassthru( req: IncomingMessage, withEncoding: true, -): - | { headers: { authorization: string }; encoding: 'application/json' } - | undefined +): { headers: AuthHeaders; encoding: 'application/json' } | undefined -export function authPassthru(req: IncomingMessage, withEncoding?: boolean) { - if (req.headers.authorization) { +export function authPassthru( + req: IncomingMessage, + withEncoding?: boolean, +): { headers: AuthHeaders; encoding?: 'application/json' } | undefined { + const { authorization, dpop } = req.headers + if (authorization && !Array.isArray(dpop)) { return { - headers: { authorization: req.headers.authorization }, + headers: { authorization, dpop }, encoding: withEncoding ? 'application/json' : undefined, } } diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 23426d98bcf..f3e89d65784 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -1,15 +1,17 @@ -import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' +import { IdResolver } from '@atproto/identity' +import { Keyset } from '@atproto/jwk' +import { DpopProvider } from '@atproto/oauth-provider' import { AuthRequiredError, ForbiddenError, InvalidRequestError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' -import * as ui8 from 'uint8arrays' import express from 'express' import * as jose from 'jose' import KeyEncoder from 'key-encoder' +import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' +import * as ui8 from 'uint8arrays' import { AccountManager } from './account-manager' import { softDeleted } from './db' @@ -94,6 +96,9 @@ type ValidatedRefreshBearer = ValidatedBearer & { } export type AuthVerifierOpts = { + dpopProvider: DpopProvider + publicUrl: string + keyset: Keyset jwtKey: KeyObject adminPass: string moderatorPass: string @@ -106,6 +111,9 @@ export type AuthVerifierOpts = { } export class AuthVerifier { + private _dpopProvider: DpopProvider + private _publicUrl: string + private _keyset: Keyset private _jwtKey: KeyObject private _adminPass: string private _moderatorPass: string @@ -117,6 +125,9 @@ export class AuthVerifier { public idResolver: IdResolver, opts: AuthVerifierOpts, ) { + this._dpopProvider = opts.dpopProvider + this._publicUrl = new URL(opts.publicUrl).href + this._keyset = opts.keyset this._jwtKey = opts.jwtKey this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass @@ -321,7 +332,15 @@ export class AuthVerifier { throw new AuthRequiredError(undefined, 'AuthMissing') } - const { payload } = await this.jwtVerify(token, verifyOptions) + const { payload, protectedHeader } = await this.jwtVerify( + token, + verifyOptions, + ) + + if (protectedHeader.typ) { + // Only OAuth Provider sets this claim + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } const { sub, aud, scope } = payload if (typeof sub !== 'string' || !sub.startsWith('did:')) { @@ -357,6 +376,8 @@ export class AuthVerifier { switch (type) { case BEARER: return this.validateBearerAccessToken(req, scopes) + case DPOP: + return this.validateDpopAccessToken(req, scopes) case null: throw new AuthRequiredError(undefined, 'AuthMissing') default: @@ -367,6 +388,64 @@ export class AuthVerifier { } } + async validateDpopAccessToken( + req: express.Request, + scopes: AuthScope[], + ): Promise { + const [tokenType, token] = parseAuthorizationHeader( + req.headers.authorization, + ) + if (tokenType !== DPOP) { + throw new InvalidRequestError( + 'Unexpected authorization type', + 'InvalidToken', + ) + } + + const url = new URL(req.url, this._publicUrl) + + const dpopJkt = await this._dpopProvider.dpopCheck( + req.headers.dpop, + req.method, + url.href, + token, + ) + + if (!dpopJkt) { + throw new InvalidRequestError('DPop proof required', 'InvalidToken') + } + + const { payload } = await this._keyset.verify<{ + aud: string + sub: `did:${string}` + }>(token as any, { + requiredClaims: ['aud', 'sub', 'iss', 'client_id'], + issuer: this._publicUrl, + audience: this.dids.pds, + typ: 'at+jwt', + }) + + const { sub } = payload + if (typeof sub !== 'string' || !sub.startsWith('did:')) { + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } + + if (!dpopJkt || (payload.cnf as any)?.jkt !== dpopJkt) { + // TODO add a specific error code + throw new InvalidRequestError('Invalid DPop key', 'InvalidToken') + } + + return { + credentials: { + type: 'access', + did: sub, + scope: AuthScope.Access, + audience: payload.aud, + }, + artifacts: token, + } + } + async validateBearerAccessToken( req: express.Request, scopes: AuthScope[], @@ -464,6 +543,7 @@ export class AuthVerifier { const BASIC = 'Basic' const BEARER = 'Bearer' +const DPOP = 'DPoP' export const parseAuthorizationHeader = (authorization?: string) => { const result = authorization?.split(' ', 2) @@ -473,7 +553,7 @@ export const parseAuthorizationHeader = (authorization?: string) => { const isAccessToken = (req: express.Request): boolean => { const [type] = parseAuthorizationHeader(req.headers.authorization) - return type === BEARER + return type === BEARER || type === DPOP } const isBearerToken = (req: express.Request): boolean => { diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index fb5aed8232f..1a0ef5e2b42 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -94,6 +94,7 @@ export const readEnv = (): ServerEnvironment => { crawlers: envList('PDS_CRAWLERS'), // secrets + dpopSecret: envStr('PDS_DPOP_SECRET'), jwtSecret: envStr('PDS_JWT_SECRET'), adminPassword: envStr('PDS_ADMIN_PASSWORD'), moderatorPassword: envStr('PDS_MODERATOR_PASSWORD'), @@ -199,6 +200,7 @@ export type ServerEnvironment = { crawlers?: string[] // secrets + dpopSecret?: string jwtSecret?: string adminPassword?: string moderatorPassword?: string diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index 8e18cd830f7..17300abb752 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -18,6 +18,10 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { throw new Error('Must configure plc rotation key') } + if (!env.dpopSecret) { + throw new Error('Must provide a DPoP secret') + } + if (!env.jwtSecret) { throw new Error('Must provide a JWT secret') } @@ -27,6 +31,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { } return { + dpopSecret: env.dpopSecret, jwtSecret: env.jwtSecret, adminPassword: env.adminPassword, moderatorPassword: env.moderatorPassword ?? env.adminPassword, @@ -37,6 +42,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { } export type ServerSecrets = { + dpopSecret: string jwtSecret: string adminPassword: string moderatorPassword: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index e1303a21081..2ecd73575ef 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -27,6 +27,14 @@ import { getRedisClient } from './redis' import { ActorStore, ActorStoreReader } from './actor-store' import { LocalViewer } from './read-after-write/viewer' import { OAuthManager } from './oauth-provider/oauth-manager' +import { + DpopManager, + DpopNonce, + DpopProvider, + ReplayStore, +} from '@atproto/oauth-provider' +import { OAuthReplayStoreRedis } from '@atproto/oauth-provider-replay-redis' +import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' export type AppContextOptions = { actorStore: ActorStore @@ -188,7 +196,6 @@ export class AppContext { cfg.db.accountDbLoc, jwtSecretKey, cfg.service.did, - cfg.service.publicUrl, cfg.db.disableWalAutoCheckpoint, ) await accountManager.migrateOrThrow() @@ -197,38 +204,53 @@ export class AppContext { ? createPublicKeyObject(cfg.entryway.jwtPublicKeyHex) : jwtSecretKey - const authVerifier = new AuthVerifier(accountManager, idResolver, { - jwtKey, - adminPass: secrets.adminPassword, - moderatorPass: secrets.moderatorPassword, - triagePass: secrets.triagePassword, - dids: { - pds: cfg.service.did, - entryway: cfg.entryway?.did, - admin: cfg.modService?.did, - }, + const keyset = await NodeKeyset.fromImportables({ + // @TODO: load keys from config + ['s0']: jwtKey, + ['kid-1']: + '-----BEGIN PRIVATE KEY-----\n' + + 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4D4H8/CFAVuKMgQD\n' + + 'BIK9m53AEUrCxQKrgtMNSTNV9A2hRANCAARAwyllCZOflLEQM0MaYujz7ITxqczZ\n' + + '6Vxhj4urrdXUN3MEliQcc14ImTWHt7h7+xbxIXETLj0kTzctAxSbtwZf\n' + + '-----END PRIVATE KEY-----\n', }) + const dpopNonce = new DpopNonce(Buffer.from(secrets.dpopSecret, 'hex')) + const replayStore: ReplayStore = redisScratch + ? new OAuthReplayStoreRedis(redisScratch) + : new OAuthReplayStoreMemory() + const oauthManager = cfg.entryway ? undefined : new OAuthManager( cfg.service.publicUrl, - // @TODO: load more keys from config - await NodeKeyset.fromImportables({ - ['kid-0']: - '-----BEGIN PRIVATE KEY-----\n' + - 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4D4H8/CFAVuKMgQD\n' + - 'BIK9m53AEUrCxQKrgtMNSTNV9A2hRANCAARAwyllCZOflLEQM0MaYujz7ITxqczZ\n' + - '6Vxhj4urrdXUN3MEliQcc14ImTWHt7h7+xbxIXETLj0kTzctAxSbtwZf\n' + - '-----END PRIVATE KEY-----\n', - }), + keyset, accountManager, - redisScratch, + replayStore, + dpopNonce, // TODO: from config process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test', ) + const dpopProvider: DpopProvider = + oauthManager ?? new DpopProvider({ dpopNonce, replayStore }) + + const authVerifier = new AuthVerifier(accountManager, idResolver, { + dpopProvider, + keyset, + jwtKey, + adminPass: secrets.adminPassword, + moderatorPass: secrets.moderatorPassword, + triagePass: secrets.triagePassword, + publicUrl: cfg.service.publicUrl, + dids: { + pds: cfg.service.did, + entryway: cfg.entryway?.did, + admin: cfg.modService?.did, + }, + }) + const plcRotationKey = secrets.plcRotationKey.provider === 'kms' ? await KmsKeypair.load({ diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index 713f661b8d6..9a1ef4a2c7c 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -45,6 +45,15 @@ export const loggerMiddleware = pinoHttp({ auth = 'Basic ' + parsed.username } } + if (authHeader.startsWith('DPoP ')) { + const token = authHeader.slice('DPoP '.length) + const { iss } = jose.decodeJwt(token) + if (iss) { + auth = 'DPoP ' + iss + } else { + auth = 'DPoP Invalid' + } + } return { ...serialized, headers: { diff --git a/packages/pds/src/oauth-provider/oauth-manager.ts b/packages/pds/src/oauth-provider/oauth-manager.ts index cb9e183b4f1..bbdf932b910 100644 --- a/packages/pds/src/oauth-provider/oauth-manager.ts +++ b/packages/pds/src/oauth-provider/oauth-manager.ts @@ -2,40 +2,38 @@ import { isAbsolute, relative } from 'node:path' import { Keyset } from '@atproto/jwk' import { + DpopNonce, InvalidClientMetadataError, InvalidRedirectUriError, OAuthProvider, + ReplayStore, parseRedirectUri, } from '@atproto/oauth-provider' import { OAuthClientUriStore } from '@atproto/oauth-provider-client-uri' -import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' -import { - OAuthReplayStoreRedis, - type OAuthReplayStoreRedisOptions, -} from '@atproto/oauth-provider-replay-redis' import { AccountManager } from '../account-manager/index.js' import { oauthProviderLogger } from '../logger.js' -export class OAuthManager { - private readonly provider: OAuthProvider - +export class OAuthManager extends OAuthProvider { constructor( issuer: string | URL, keyset: Keyset, accountManager: AccountManager, - redis?: OAuthReplayStoreRedisOptions, + replayStore: ReplayStore, + dpopNonce?: DpopNonce, unsafeFetch = process.env.NODE_ENV === 'development', ) { - this.provider = new OAuthProvider({ + super({ issuer, keyset, + dpopNonce, accountStore: accountManager, requestStore: accountManager, sessionStore: accountManager, tokenStore: accountManager, + replayStore, clientStore: new OAuthClientUriStore({ // In dev, it can be useful to disable SSRF & other protections. unsafeFetch, @@ -63,9 +61,6 @@ export class OAuthManager { dpop_bound_access_tokens: true, }), }), - replayStore: redis - ? new OAuthReplayStoreRedis(redis) - : new OAuthReplayStoreMemory(), onTokenResponse: (tokenResponse, { account }) => { // ATPROTO extension: add the sub claim to the token response to allow @@ -148,7 +143,7 @@ export class OAuthManager { } get router() { - return this.provider.httpHandler({ + return this.httpHandler({ // Log oauth provider errors using our own logger onError: (req, res, err) => { oauthProviderLogger.error({ err }, 'oauth-provider error') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa410854ba9..5ffccee11f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -604,6 +604,9 @@ importers: packages/jwk: dependencies: + jose: + specifier: ^5.2.2 + version: 5.2.2 tslib: specifier: ^2.6.2 version: 2.6.2 diff --git a/services/pds/actors/17/did:plc:4ozujjsqicbdzuit2xnf3j2p/key b/services/pds/actors/17/did:plc:4ozujjsqicbdzuit2xnf3j2p/key new file mode 100644 index 00000000000..82ac30be184 --- /dev/null +++ b/services/pds/actors/17/did:plc:4ozujjsqicbdzuit2xnf3j2p/key @@ -0,0 +1 @@ +d$AN3E>֎ { - maintainXrpcResource(span, req) - }, - }, - }) +// const { TracerProvider } = require('dd-trace') // Only works with commonjs +// .init({ logInjection: true }) +// .use('express', { +// hooks: { +// request: (span, req) => { +// maintainXrpcResource(span, req) +// }, +// }, +// }) -const tracer = new TracerProvider() -tracer.register() +// const tracer = new TracerProvider() +// tracer.register() -registerInstrumentations({ - tracerProvider: tracer, - instrumentations: [new BetterSqlite3Instrumentation()], -}) +// registerInstrumentations({ +// tracerProvider: tracer, +// instrumentations: [new BetterSqlite3Instrumentation()], +// }) // Tracer code above must come before anything else -const path = require('path') +// const path = require('path') const { PDS, envToCfg, @@ -55,19 +55,19 @@ const main = async () => { }) } -const maintainXrpcResource = (span, req) => { - // Show actual xrpc method as resource rather than the route pattern - if (span && req.originalUrl?.startsWith('/xrpc/')) { - span.setTag( - 'resource.name', - [ - req.method, - path.posix.join(req.baseUrl || '', req.path || '', '/').slice(0, -1), // Ensures no trailing slash - ] - .filter(Boolean) - .join(' '), - ) - } -} +// const maintainXrpcResource = (span, req) => { +// // Show actual xrpc method as resource rather than the route pattern +// if (span && req.originalUrl?.startsWith('/xrpc/')) { +// span.setTag( +// 'resource.name', +// [ +// req.method, +// path.posix.join(req.baseUrl || '', req.path || '', '/').slice(0, -1), // Ensures no trailing slash +// ] +// .filter(Boolean) +// .join(' '), +// ) +// } +// } main()