diff --git a/package.json b/package.json index c19452e316..59d402a5ec 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,7 @@ "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zeego": "^1.6.2", - "zod": "^3.20.2" + "zod": "^3.22.4" }, "devDependencies": { "@atproto/dev-env": "^0.2.28", diff --git a/src/oauth-client-temp/b64/index.ts b/src/oauth-client-temp/b64/index.ts new file mode 100644 index 0000000000..6acab5725e --- /dev/null +++ b/src/oauth-client-temp/b64/index.ts @@ -0,0 +1,34 @@ +import {fromByteArray, toByteArray} from 'base64-js' + +// Old Node implementations do not support "base64url" +const Buffer = (Buffer => { + if (typeof Buffer === 'function') { + try { + Buffer.from('', 'base64url') + return Buffer + } catch { + return undefined + } + } + return undefined +})(globalThis.Buffer) + +export const b64uDecode: (b64u: string) => Uint8Array = Buffer + ? b64u => Buffer.from(b64u, 'base64url') + : b64u => { + // toByteArray requires padding but not to replace '-' and '_' + const pad = b64u.length % 4 + const b64 = b64u.padEnd(b64u.length + (pad > 0 ? 4 - pad : 0), '=') + return toByteArray(b64) + } + +export const b64uEncode = Buffer + ? (bytes: Uint8Array) => { + const buffer = bytes instanceof Buffer ? bytes : Buffer.from(bytes) + return buffer.toString('base64url') + } + : (bytes: Uint8Array): string => + fromByteArray(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/[=]+$/g, '') diff --git a/src/oauth-client-temp/client/constants.ts b/src/oauth-client-temp/client/constants.ts new file mode 100644 index 0000000000..dca10c5ce3 --- /dev/null +++ b/src/oauth-client-temp/client/constants.ts @@ -0,0 +1 @@ +export const FALLBACK_ALG = 'ES256' diff --git a/src/oauth-client-temp/client/crypto-implementation.ts b/src/oauth-client-temp/client/crypto-implementation.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/oauth-client-temp/jwk/alg.ts b/src/oauth-client-temp/jwk/alg.ts new file mode 100644 index 0000000000..226a1bb662 --- /dev/null +++ b/src/oauth-client-temp/jwk/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/src/oauth-client-temp/jwk/index.ts b/src/oauth-client-temp/jwk/index.ts new file mode 100644 index 0000000000..3e1c678233 --- /dev/null +++ b/src/oauth-client-temp/jwk/index.ts @@ -0,0 +1,9 @@ +export * from './alg.js' +export * from './jwk.js' +export * from './jwks.js' +export * from './jwt.js' +export * from './jwt-decode.js' +export * from './jwt-verify.js' +export * from './key.js' +export * from './keyset.js' +export * from './util.js' diff --git a/src/oauth-client-temp/jwk/jwk.ts b/src/oauth-client-temp/jwk/jwk.ts new file mode 100644 index 0000000000..f74a2ef377 --- /dev/null +++ b/src/oauth-client-temp/jwk/jwk.ts @@ -0,0 +1,153 @@ +import { z } from 'zod' + +export const keyUsageSchema = z.enum([ + 'sign', + 'verify', + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + 'deriveKey', + 'deriveBits', +]) + +export type KeyUsage = z.infer + +/** + * The "use" and "key_ops" JWK members SHOULD NOT be used together; + * however, if both are used, the information they convey MUST be + * consistent. Applications should specify which of these members they + * use, if either is to be used by the application. + * + * @todo Actually check that "use" and "key_ops" are consistent when both are present. + * @see {@link https://datatracker.ietf.org/doc/html/rfc7517#section-4.3} + */ +export const jwkBaseSchema = z.object({ + kty: z.string().min(1), + alg: z.string().min(1).optional(), + kid: z.string().min(1).optional(), + ext: z.boolean().optional(), + use: z.enum(['sig', 'enc']).optional(), + key_ops: z.array(keyUsageSchema).readonly().optional(), + + x5c: z.array(z.string()).readonly().optional(), // X.509 Certificate Chain + x5t: z.string().min(1).optional(), // X.509 Certificate SHA-1 Thumbprint + 'x5t#S256': z.string().min(1).optional(), // X.509 Certificate SHA-256 Thumbprint + x5u: z.string().url().optional(), // X.509 URL +}) + +/** + * @todo: properly implement this + */ +export const jwkRsaKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('RSA'), + alg: z + .enum(['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512']) + .optional(), + + n: z.string().min(1), // Modulus + e: z.string().min(1), // Exponent + + d: z.string().min(1).optional(), // Private Exponent + p: z.string().min(1).optional(), // First Prime Factor + q: z.string().min(1).optional(), // Second Prime Factor + dp: z.string().min(1).optional(), // First Factor CRT Exponent + dq: z.string().min(1).optional(), // Second Factor CRT Exponent + qi: z.string().min(1).optional(), // First CRT Coefficient + oth: z + .array( + z + .object({ + r: z.string().optional(), + d: z.string().optional(), + t: z.string().optional(), + }) + .readonly(), + ) + .nonempty() + .readonly() + .optional(), // Other Primes Info + }) + .readonly() + +export const jwkEcKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('EC'), + alg: z.enum(['ES256', 'ES384', 'ES512']).optional(), + crv: z.enum(['P-256', 'P-384', 'P-521']), + + x: z.string().min(1), + y: z.string().min(1), + + d: z.string().min(1).optional(), // ECC Private Key + }) + .readonly() + +export const jwkEcSecp256k1KeySchema = jwkBaseSchema + .extend({ + kty: z.literal('EC'), + alg: z.enum(['ES256K']).optional(), + crv: z.enum(['secp256k1']), + + x: z.string().min(1), + y: z.string().min(1), + + d: z.string().min(1).optional(), // ECC Private Key + }) + .readonly() + +export const jwkOkpKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('OKP'), + alg: z.enum(['EdDSA']).optional(), + crv: z.enum(['Ed25519', 'Ed448']), + + x: z.string().min(1), + d: z.string().min(1).optional(), // ECC Private Key + }) + .readonly() + +export const jwkSymKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('oct'), // Octet Sequence (used to represent symmetric keys) + alg: z.enum(['HS256', 'HS384', 'HS512']).optional(), + + k: z.string(), // Key Value (base64url encoded) + }) + .readonly() + +export const jwkUnknownKeySchema = jwkBaseSchema + .extend({ + kty: z + .string() + .refine((v) => v !== 'RSA' && v !== 'EC' && v !== 'OKP' && v !== 'oct'), + }) + .readonly() + +export const jwkSchema = z.union([ + jwkUnknownKeySchema, + jwkRsaKeySchema, + jwkEcKeySchema, + jwkEcSecp256k1KeySchema, + jwkOkpKeySchema, + jwkSymKeySchema, +]) + +export type Jwk = z.infer + +export const jwkPubSchema = jwkSchema + .refine((k) => k.kid != null, 'kid is required') + .refine((k) => k.use != null || k.key_ops != null, 'use or key_ops required') + .refine( + (k) => + !k.use || + !k.key_ops || + k.key_ops.every((o) => + k.use === 'sig' + ? o === 'sign' || o === 'verify' + : o === 'encrypt' || o === 'decrypt', + ), + 'use and key_ops must be consistent', + ) + .refine((k) => !('k' in k) && !('d' in k), 'private key not allowed') diff --git a/src/oauth-client-temp/jwk/jwks.ts b/src/oauth-client-temp/jwk/jwks.ts new file mode 100644 index 0000000000..1ec8d382ba --- /dev/null +++ b/src/oauth-client-temp/jwk/jwks.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +import { jwkPubSchema, jwkSchema } from './jwk.js' + +export const jwksSchema = z + .object({ + keys: z.array(jwkSchema).readonly(), + }) + .readonly() + +export type Jwks = z.infer + +export const jwksPubSchema = z + .object({ + keys: z.array(jwkPubSchema).readonly(), + }) + .readonly() + +export type JwksPub = z.infer diff --git a/src/oauth-client-temp/jwk/jwt-decode.ts b/src/oauth-client-temp/jwk/jwt-decode.ts new file mode 100644 index 0000000000..287f8fbc08 --- /dev/null +++ b/src/oauth-client-temp/jwk/jwt-decode.ts @@ -0,0 +1,32 @@ +import { b64uDecode } from '@atproto/b64' + +import { ui8ToString } from './util.js' +import { + JwtHeader, + JwtPayload, + jwtHeaderSchema, + jwtPayloadSchema, +} from './jwt.js' + +export function unsafeDecodeJwt(jwt: string): { + header: JwtHeader + payload: JwtPayload +} { + const { 0: headerEnc, 1: payloadEnc, length } = jwt.split('.') + if (length > 3 || length < 2) { + throw new TypeError('invalid JWT input') + } + + const header = jwtHeaderSchema.parse( + JSON.parse(ui8ToString(b64uDecode(headerEnc!))), + ) + if (length === 2 && header?.alg !== 'none') { + throw new TypeError('invalid JWT input') + } + + const payload = jwtPayloadSchema.parse( + JSON.parse(ui8ToString(b64uDecode(payloadEnc!))), + ) + + return { header, payload } +} diff --git a/src/oauth-client-temp/jwk/jwt-verify.ts b/src/oauth-client-temp/jwk/jwt-verify.ts new file mode 100644 index 0000000000..5eeca81e53 --- /dev/null +++ b/src/oauth-client-temp/jwk/jwt-verify.ts @@ -0,0 +1,20 @@ +import { JwtHeader, JwtPayload } from './jwt.js' +import { RequiredKey } from './util.js' + +export type VerifyOptions = { + audience?: string | readonly string[] + clockTolerance?: string | number + issuer?: string | readonly string[] + maxTokenAge?: string | number + subject?: string + typ?: string + currentDate?: Date + requiredClaims?: readonly C[] +} + +export type VerifyPayload = Record + +export type VerifyResult

= { + payload: RequiredKey

+ protectedHeader: JwtHeader +} diff --git a/src/oauth-client-temp/jwk/jwt.ts b/src/oauth-client-temp/jwk/jwt.ts new file mode 100644 index 0000000000..c07af8cb73 --- /dev/null +++ b/src/oauth-client-temp/jwk/jwt.ts @@ -0,0 +1,172 @@ +import { z } from 'zod' + +import { jwkPubSchema } from './jwk.js' + +export const JWT_REGEXP = /^[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){1,2}$/ +export const jwtSchema = z + .string() + .min(5) + .refinement( + (data: string): data is `${string}.${string}.${string}` => + JWT_REGEXP.test(data), + { + code: z.ZodIssueCode.custom, + message: 'Must be a JWT', + }, + ) + +export const isJwt = (data: unknown): data is Jwt => + jwtSchema.safeParse(data).success + +export type Jwt = z.infer + +/** + * @see {@link https://www.rfc-editor.org/rfc/rfc7515.html#section-4} + */ +export const jwtHeaderSchema = z.object({ + /** "alg" (Algorithm) Header Parameter */ + alg: z.string(), + /** "jku" (JWK Set URL) Header Parameter */ + jku: z.string().url().optional(), + /** "jwk" (JSON Web Key) Header Parameter */ + jwk: z + .object({ + kty: z.string(), + crv: z.string().optional(), + x: z.string().optional(), + y: z.string().optional(), + e: z.string().optional(), + n: z.string().optional(), + }) + .optional(), + /** "kid" (Key ID) Header Parameter */ + kid: z.string().optional(), + /** "x5u" (X.509 URL) Header Parameter */ + x5u: z.string().optional(), + /** "x5c" (X.509 Certificate Chain) Header Parameter */ + x5c: z.array(z.string()).optional(), + /** "x5t" (X.509 Certificate SHA-1 Thumbprint) Header Parameter */ + x5t: z.string().optional(), + /** "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header Parameter */ + 'x5t#S256': z.string().optional(), + /** "typ" (Type) Header Parameter */ + typ: z.string().optional(), + /** "cty" (Content Type) Header Parameter */ + cty: z.string().optional(), + /** "crit" (Critical) Header Parameter */ + crit: z.array(z.string()).optional(), +}) + +export type JwtHeader = z.infer + +// https://www.iana.org/assignments/jwt/jwt.xhtml +export const jwtPayloadSchema = z.object({ + iss: z.string().optional(), + aud: z.union([z.string(), z.array(z.string()).nonempty()]).optional(), + sub: z.string().optional(), + exp: z.number().int().optional(), + nbf: z.number().int().optional(), + iat: z.number().int().optional(), + jti: z.string().optional(), + htm: z.string().optional(), + htu: z.string().optional(), + ath: z.string().optional(), + acr: z.string().optional(), + azp: z.string().optional(), + amr: z.array(z.string()).optional(), + // https://datatracker.ietf.org/doc/html/rfc7800 + cnf: z + .object({ + kid: z.string().optional(), // Key ID + jwk: jwkPubSchema.optional(), // JWK + jwe: z.string().optional(), // Encrypted key + jku: z.string().url().optional(), // JWK Set URI ("kid" should also be provided) + + // https://datatracker.ietf.org/doc/html/rfc9449#section-6.1 + jkt: z.string().optional(), + + // https://datatracker.ietf.org/doc/html/rfc8705 + 'x5t#S256': z.string().optional(), // X.509 Certificate SHA-256 Thumbprint + + // https://datatracker.ietf.org/doc/html/rfc9203 + osc: z.string().optional(), // OSCORE_Input_Material carrying the parameters for using OSCORE per-message security with implicit key confirmation + }) + .optional(), + + client_id: z.string().optional(), + + scope: z.string().optional(), + nonce: z.string().optional(), + + at_hash: z.string().optional(), + c_hash: z.string().optional(), + s_hash: z.string().optional(), + auth_time: z.number().int().optional(), + + // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + + // OpenID: "profile" scope + name: z.string().optional(), + family_name: z.string().optional(), + given_name: z.string().optional(), + middle_name: z.string().optional(), + nickname: z.string().optional(), + preferred_username: z.string().optional(), + gender: z.string().optional(), // OpenID only defines "male" and "female" without forbidding other values + picture: z.string().url().optional(), + profile: z.string().url().optional(), + website: z.string().url().optional(), + birthdate: z + .string() + .regex(/\d{4}-\d{2}-\d{2}/) // YYYY-MM-DD + .optional(), + zoneinfo: z + .string() + .regex(/^[A-Za-z0-9_/]+$/) + .optional(), + locale: z + .string() + .regex(/^[a-z]{2}(-[A-Z]{2})?$/) + .optional(), + updated_at: z.number().int().optional(), + + // OpenID: "email" scope + email: z.string().optional(), + email_verified: z.boolean().optional(), + + // OpenID: "phone" scope + phone_number: z.string().optional(), + phone_number_verified: z.boolean().optional(), + + // OpenID: "address" scope + // https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim + address: z + .object({ + formatted: z.string().optional(), + street_address: z.string().optional(), + locality: z.string().optional(), + region: z.string().optional(), + postal_code: z.string().optional(), + country: z.string().optional(), + }) + .optional(), + + // https://datatracker.ietf.org/doc/html/rfc9396#section-14.2 + authorization_details: z + .array( + z + .object({ + type: z.string(), + // https://datatracker.ietf.org/doc/html/rfc9396#section-2.2 + locations: z.array(z.string()).optional(), + actions: z.array(z.string()).optional(), + datatypes: z.array(z.string()).optional(), + identifier: z.string().optional(), + privileges: z.array(z.string()).optional(), + }) + .passthrough(), + ) + .optional(), +}) + +export type JwtPayload = z.infer diff --git a/src/oauth-client-temp/jwk/key.ts b/src/oauth-client-temp/jwk/key.ts new file mode 100644 index 0000000000..d8af69e0df --- /dev/null +++ b/src/oauth-client-temp/jwk/key.ts @@ -0,0 +1,95 @@ +import { jwkAlgorithms } from './alg.js' +import { Jwk, jwkSchema } from './jwk.js' +import { VerifyOptions, VerifyPayload, VerifyResult } from './jwt-verify.js' +import { Jwt, JwtHeader, JwtPayload } from './jwt.js' +import { cachedGetter } from './util.js' + +export abstract class Key { + constructor(protected jwk: Jwk) { + // A key should always be used either for signing or encryption. + if (!jwk.use) throw new TypeError('Missing "use" Parameter value') + } + + get isPrivate(): boolean { + const { jwk } = this + if ('d' in jwk && jwk.d !== undefined) return true + return this.isSymetric + } + + get isSymetric(): boolean { + const { jwk } = this + if ('k' in jwk && jwk.k !== undefined) return true + return false + } + + get privateJwk(): Jwk | undefined { + return this.isPrivate ? this.jwk : undefined + } + + @cachedGetter + get publicJwk(): Jwk | undefined { + if (this.isSymetric) return undefined + if (this.isPrivate) { + const { d: _, ...jwk } = this.jwk as any + return jwk + } + return this.jwk + } + + @cachedGetter + get bareJwk(): Jwk | undefined { + if (this.isSymetric) return undefined + const { kty, crv, e, n, x, y } = this.jwk as any + return jwkSchema.parse({ crv, e, kty, n, x, y }) + } + + get use() { + return this.jwk.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 this.jwk.alg + } + + get kid() { + return this.jwk.kid + } + + get crv() { + return (this.jwk as undefined | Extract)?.crv + } + + get canVerify() { + return this.use === 'sig' + } + + get canSign() { + return this.use === 'sig' && this.isPrivate && !this.isSymetric + } + + /** + * 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[] { + return Array.from(jwkAlgorithms(this.jwk)) + } + + /** + * Create a signed JWT + */ + abstract createJwt(header: JwtHeader, payload: JwtPayload): Promise + + /** + * Verify the signature, headers and payload of a JWT + */ + abstract verifyJwt< + P extends VerifyPayload = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions): Promise> +} diff --git a/src/oauth-client-temp/jwk/keyset.ts b/src/oauth-client-temp/jwk/keyset.ts new file mode 100644 index 0000000000..9be83677d3 --- /dev/null +++ b/src/oauth-client-temp/jwk/keyset.ts @@ -0,0 +1,200 @@ +import { Jwk } from './jwk.js' +import { Jwks } from './jwks.js' +import { unsafeDecodeJwt } from './jwt-decode.js' +import { VerifyOptions } from './jwt-verify.js' +import { Jwt, JwtHeader, JwtPayload } from './jwt.js' +import { Key } from './key.js' +import { + Override, + cachedGetter, + isDefined, + matchesAny, + preferredOrderCmp, +} from './util.js' + +export type JwtSignHeader = Override> + +export type JwtPayloadGetter

= ( + header: JwtHeader, + key: Key, +) => P | PromiseLike

+ +export type KeySearch = { + use?: 'sig' | 'enc' + kid?: string | 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( + 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 { kid } of keys) { + if (!kid) continue + + if (kids.has(kid)) throw new Error(`Duplicate key id: ${kid}`) + else kids.add(kid) + } + } + + @cachedGetter + get signAlgorithms(): readonly string[] { + const algorithms = new Set() + for (const key of this) { + if (key.use !== 'sig') continue + for (const alg of key.algorithms) { + algorithms.add(alg) + } + } + return Object.freeze( + [...algorithms].sort(preferredOrderCmp(this.preferredSigningAlgorithms)), + ) + } + + @cachedGetter + get publicJwks(): Jwks { + return { + 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.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 || ''}`, + ) + } + + *list(search: KeySearch): Generator { + // Optimization: Empty string or empty array will not match any key + if (search.kid?.length === 0) return + if (search.alg?.length === 0) return + + for (const key of this) { + if (search.use && key.use !== search.use) continue + + if (Array.isArray(search.kid)) { + if (!key.kid || !search.kid.includes(key.kid)) continue + } else if (search.kid) { + if (key.kid !== search.kid) continue + } + + if (Array.isArray(search.alg)) { + if (!search.alg.some((a) => key.algorithms.includes(a))) continue + } else if (typeof search.alg === 'string') { + if (!key.algorithms.includes(search.alg)) continue + } + + yield 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, + ) + + // 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 || ''}`) + } + + [Symbol.iterator](): IterableIterator { + return this.keys.values() + } + + async sign( + { alg: searchAlg, kid: searchKid, ...header }: JwtSignHeader, + payload: JwtPayload | JwtPayloadGetter, + ) { + const [key, alg] = this.findSigningKey({ alg: searchAlg, kid: searchKid }) + const protectedHeader = { ...header, alg, kid: key.kid } + + if (typeof payload === 'function') { + payload = await payload(protectedHeader, key) + } + + return key.createJwt(protectedHeader, payload) + } + + async verify< + P extends Record = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions) { + const { header } = unsafeDecodeJwt(token) + const { kid, alg } = header + + const errors: unknown[] = [] + + for (const key of this.list({ use: 'sig', kid, alg })) { + try { + return await key.verifyJwt(token, options) + } catch (err) { + errors.push(err) + } + } + + throw new AggregateError(errors, 'Unable to verify signature') + } +} diff --git a/src/oauth-client-temp/jwk/util.ts b/src/oauth-client-temp/jwk/util.ts new file mode 100644 index 0000000000..12b5625cce --- /dev/null +++ b/src/oauth-client-temp/jwk/util.ts @@ -0,0 +1,55 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type Simplify = { [K in keyof T]: T[K] } & {} +export type Override = Simplify> + +export type RequiredKey = Simplify< + string extends K + ? T + : { + [L in K]: Exclude + } & Omit +> + +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 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 +} + +/** + * 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 + } +} + +export const decoder = new TextDecoder() +export const ui8ToString = (value: Uint8Array) => decoder.decode(value) diff --git a/src/screens/Login/hooks/package.json b/src/screens/Login/hooks/package.json new file mode 100644 index 0000000000..3a1fb0a9b1 --- /dev/null +++ b/src/screens/Login/hooks/package.json @@ -0,0 +1,14 @@ +{ + "name": "hooks", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/social-app.git" + }, + "private": true +} diff --git a/yarn.lock b/yarn.lock index e7749a47b4..261339a1ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22275,7 +22275,12 @@ zod@3.21.4: resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw== -zod@^3.14.2, zod@^3.20.2, zod@^3.21.4: +zod@^3.14.2, zod@^3.21.4: version "3.22.2" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b" integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg== + +zod@^3.22.4: + version "3.22.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" + integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==