diff --git a/src/oauth-client-temp/b64/index.ts b/src/oauth-client-temp/b64/index.ts deleted file mode 100644 index 6acab5725e..0000000000 --- a/src/oauth-client-temp/b64/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index dca10c5ce3..0000000000 --- a/src/oauth-client-temp/client/constants.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 237485068d..0000000000 --- a/src/oauth-client-temp/client/crypto-implementation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {CryptoKey} from '#/oauth-client-temp/client/key' -import {Key} from '#/oauth-client-temp/jwk' - -// TODO this might not be necessary with this setup, we will see - -export type DigestAlgorithm = { - name: 'sha256' | 'sha384' | 'sha512' -} - -export class CryptoImplementation { - public async createKey(algs: string[]): Promise { - return CryptoKey.generate(undefined, algs) - } - - getRandomValues(byteLength: number): Uint8Array { - const bytes = new Uint8Array(byteLength) - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - return crypto.getRandomValues(bytes) - } - - async digest( - bytes: Uint8Array, - algorithm: DigestAlgorithm, - ): Promise { - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - const buffer = await this.crypto.subtle.digest( - digestAlgorithmToSubtle(algorithm), - bytes, - ) - return new Uint8Array(buffer) - } -} - -// TODO OAUTH types -// @ts-ignore -function digestAlgorithmToSubtle({name}: DigestAlgorithm): AlgorithmIdentifier { - switch (name) { - case 'sha256': - case 'sha384': - case 'sha512': - return `SHA-${name.slice(-3)}` - default: - throw new Error(`Unknown hash algorithm ${name}`) - } -} diff --git a/src/oauth-client-temp/client/crypto-wrapper.ts b/src/oauth-client-temp/client/crypto-wrapper.ts deleted file mode 100644 index d893319c61..0000000000 --- a/src/oauth-client-temp/client/crypto-wrapper.ts +++ /dev/null @@ -1,173 +0,0 @@ -import {b64uEncode} from '#/oauth-client-temp/b64' -import { - JwtHeader, - JwtPayload, - Key, - unsafeDecodeJwt, -} from '#/oauth-client-temp/jwk' -import {CryptoImplementation, DigestAlgorithm} from './crypto-implementation' - -export class CryptoWrapper { - constructor(protected implementation: CryptoImplementation) {} - - public async generateKey(algs: string[]): Promise { - return this.implementation.createKey(algs) - } - - public async sha256(text: string): Promise { - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - const bytes = new TextEncoder().encode(text) - const digest = await this.implementation.digest(bytes, {name: 'sha256'}) - return b64uEncode(digest) - } - - public async generateNonce(length = 16): Promise { - const bytes = await this.implementation.getRandomValues(length) - return b64uEncode(bytes) - } - - public async validateIdTokenClaims( - token: string, - state: string, - nonce: string, - code?: string, - accessToken?: string, - ): Promise<{ - header: JwtHeader - payload: JwtPayload - }> { - // It's fine to use unsafeDecodeJwt here because the token was received from - // the server's token endpoint. The following checks are to ensure that the - // oauth flow was indeed initiated by the client. - const {header, payload} = unsafeDecodeJwt(token) - if (!payload.nonce || payload.nonce !== nonce) { - throw new TypeError('Nonce mismatch') - } - if (payload.c_hash) { - await this.validateHashClaim(payload.c_hash, code, header) - } - if (payload.s_hash) { - await this.validateHashClaim(payload.s_hash, state, header) - } - if (payload.at_hash) { - await this.validateHashClaim(payload.at_hash, accessToken, header) - } - return {header, payload} - } - - private async validateHashClaim( - claim: unknown, - source: unknown, - header: {alg: string; crv?: string}, - ): Promise { - if (typeof claim !== 'string' || !claim) { - throw new TypeError(`string "_hash" claim expected`) - } - if (typeof source !== 'string' || !source) { - throw new TypeError(`string value expected`) - } - const expected = await this.generateHashClaim(source, header) - if (expected !== claim) { - throw new TypeError(`"_hash" does not match`) - } - } - - protected async generateHashClaim( - source: string, - header: {alg: string; crv?: string}, - ) { - const algo = getHashAlgo(header) - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - const bytes = new TextEncoder().encode(source) - const digest = await this.implementation.digest(bytes, algo) - return b64uEncode(digest.slice(0, digest.length / 2)) - } - - public async generatePKCE(byteLength?: number) { - const verifier = await this.generateVerifier(byteLength) - return { - verifier, - challenge: await this.sha256(verifier), - method: 'S256', - } - } - - // TODO OAUTH types - public async calculateJwkThumbprint(jwk: any) { - const components = extractJktComponents(jwk) - const data = JSON.stringify(components) - return this.sha256(data) - } - - /** - * @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1} - * @note It is RECOMMENDED that the output of a suitable random number generator - * be used to create a 32-octet sequence. The octet sequence is then - * base64url-encoded to produce a 43-octet URL safe string to use as the code - * verifier. - */ - protected async generateVerifier(byteLength = 32) { - if (byteLength < 32 || byteLength > 96) { - throw new TypeError('Invalid code_verifier length') - } - const bytes = await this.implementation.getRandomValues(byteLength) - return b64uEncode(bytes) - } -} - -function getHashAlgo(header: {alg: string; crv?: string}): DigestAlgorithm { - switch (header.alg) { - case 'HS256': - case 'RS256': - case 'PS256': - case 'ES256': - case 'ES256K': - return {name: 'sha256'} - case 'HS384': - case 'RS384': - case 'PS384': - case 'ES384': - return {name: 'sha384'} - case 'HS512': - case 'RS512': - case 'PS512': - case 'ES512': - return {name: 'sha512'} - case 'EdDSA': - switch (header.crv) { - case 'Ed25519': - return {name: 'sha512'} - default: - throw new TypeError('unrecognized or invalid EdDSA curve provided') - } - default: - throw new TypeError('unrecognized or invalid JWS algorithm provided') - } -} - -// TODO OAUTH types -function extractJktComponents(jwk: {[x: string]: any; kty: any}) { - // TODO OAUTH types - const get = (field: string) => { - const value = jwk[field] - if (typeof value !== 'string' || !value) { - throw new TypeError(`"${field}" Parameter missing or invalid`) - } - return value - } - - switch (jwk.kty) { - case 'EC': - return {crv: get('crv'), kty: get('kty'), x: get('x'), y: get('y')} - case 'OKP': - return {crv: get('crv'), kty: get('kty'), x: get('x')} - case 'RSA': - return {e: get('e'), kty: get('kty'), n: get('n')} - case 'oct': - return {k: get('k'), kty: get('kty')} - default: - throw new TypeError('"kty" (Key Type) Parameter missing or unsupported') - } -} diff --git a/src/oauth-client-temp/client/db.ts b/src/oauth-client-temp/client/db.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/oauth-client-temp/client/db.web.ts b/src/oauth-client-temp/client/db.web.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/oauth-client-temp/client/key.ts b/src/oauth-client-temp/client/key.ts deleted file mode 100644 index 228e0471d1..0000000000 --- a/src/oauth-client-temp/client/key.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - fromSubtleAlgorithm, - generateKeypair, - isSignatureKeyPair, -} from '#/oauth-client-temp/client/util' -import {Jwk, jwkSchema} from '#/oauth-client-temp/jwk' -import {JoseKey} from '#/oauth-client-temp/jwk-jose' - -export class CryptoKey extends JoseKey { - // static async fromIndexedDB(kid: string, allowedAlgos: string[] = ['ES384']) { - // const cryptoKeyPair = await loadCryptoKeyPair(kid, allowedAlgos) - // return this.fromKeypair(kid, cryptoKeyPair) - // } - - static async generate( - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - kid: string = crypto.randomUUID(), - allowedAlgos: string[] = ['ES384'], - exportable = false, - ) { - const cryptoKeyPair = await generateKeypair(allowedAlgos, exportable) - return this.fromKeypair(kid, cryptoKeyPair) - } - - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - static async fromKeypair(kid: string, cryptoKeyPair: CryptoKeyPair) { - if (!isSignatureKeyPair(cryptoKeyPair)) { - throw new TypeError('CryptoKeyPair must be compatible with sign/verify') - } - - // https://datatracker.ietf.org/doc/html/rfc7517 - // > The "use" and "key_ops" JWK members SHOULD NOT be used together; [...] - // > Applications should specify which of these members they use. - - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - const {key_ops: _, ...jwk} = await crypto.subtle.exportKey( - 'jwk', - cryptoKeyPair.privateKey.extractable - ? cryptoKeyPair.privateKey - : cryptoKeyPair.publicKey, - ) - - const use = jwk.use ?? 'sig' - const alg = - jwk.alg ?? fromSubtleAlgorithm(cryptoKeyPair.privateKey.algorithm) - - if (use !== 'sig') { - throw new TypeError('Unsupported JWK use') - } - - return new CryptoKey( - jwkSchema.parse({...jwk, use, kid, alg}), - cryptoKeyPair, - ) - } - - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - constructor(jwk: Jwk, readonly cryptoKeyPair: CryptoKeyPair) { - super(jwk) - } - - get isPrivate() { - return true - } - - get privateJwk(): Jwk | undefined { - if (super.isPrivate) return this.jwk - throw new Error('Private Webcrypto Key not exportable') - } - - protected async getKey() { - return this.cryptoKeyPair.privateKey - } -} diff --git a/src/oauth-client-temp/client/keyset.ts b/src/oauth-client-temp/client/keyset.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/oauth-client-temp/client/oauth-callback-error.ts b/src/oauth-client-temp/client/oauth-callback-error.ts deleted file mode 100644 index 9c9c26d19d..0000000000 --- a/src/oauth-client-temp/client/oauth-callback-error.ts +++ /dev/null @@ -1,16 +0,0 @@ -export class OAuthCallbackError extends Error { - static from(err: unknown, params: URLSearchParams, state?: string) { - if (err instanceof OAuthCallbackError) return err - const message = err instanceof Error ? err.message : undefined - return new OAuthCallbackError(params, message, state, err) - } - - constructor( - public readonly params: URLSearchParams, - message = params.get('error_description') || 'OAuth callback error', - public readonly state?: string, - cause?: unknown, - ) { - super(message, { cause }) - } -} diff --git a/src/oauth-client-temp/client/oauth-client-factory.ts b/src/oauth-client-temp/client/oauth-client-factory.ts deleted file mode 100644 index 8ca685a94a..0000000000 --- a/src/oauth-client-temp/client/oauth-client-factory.ts +++ /dev/null @@ -1,290 +0,0 @@ -import {GenericStore} from '@atproto/caching' - -import {Key} from '#/oauth-client-temp/jwk' -import {FALLBACK_ALG} from './constants' -import {OAuthCallbackError} from './oauth-callback-error' -import {OAuthClient} from './oauth-client' -import {OAuthServer} from './oauth-server' -import { - OAuthServerFactory, - OAuthServerFactoryOptions, -} from './oauth-server-factory' -import { - OAuthAuthorizeOptions, - OAuthResponseMode, - OAuthResponseType, -} from './oauth-types' -import {Session, SessionGetter} from './session-getter' - -export type InternalStateData = { - iss: string - nonce: string - dpopKey: Key - verifier?: string - - /** - * @note This could be parametrized to be of any type. This wasn't done for - * the sake of simplicity but could be added in a later development. - */ - appState?: string -} - -export type OAuthClientOptions = OAuthServerFactoryOptions & { - stateStore: GenericStore - sessionStore: GenericStore - - /** - * "form_post" will typically be used for server-side applications. - */ - responseMode?: OAuthResponseMode - responseType?: OAuthResponseType -} - -export class OAuthClientFactory { - readonly serverFactory: OAuthServerFactory - - readonly stateStore: GenericStore - readonly sessionGetter: SessionGetter - - readonly responseMode?: OAuthResponseMode - readonly responseType?: OAuthResponseType - - constructor(options: OAuthClientOptions) { - this.responseMode = options?.responseMode - this.responseType = options?.responseType - this.serverFactory = new OAuthServerFactory(options) - this.stateStore = options.stateStore - this.sessionGetter = new SessionGetter( - options.sessionStore, - this.serverFactory, - ) - } - - get clientMetadata() { - return this.serverFactory.clientMetadata - } - - async authorize( - input: string, - options?: OAuthAuthorizeOptions, - ): Promise { - const {did, metadata} = await this.serverFactory.resolver.resolve(input) - - const nonce = await this.serverFactory.crypto.generateNonce() - const pkce = await this.serverFactory.crypto.generatePKCE() - const dpopKey = await this.serverFactory.crypto.generateKey( - metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG], - ) - - const state = await this.serverFactory.crypto.generateNonce() - - await this.stateStore.set(state, { - iss: metadata.issuer, - dpopKey, - nonce, - verifier: pkce?.verifier, - appState: options?.state, - }) - - const parameters = { - client_id: this.clientMetadata.client_id, - redirect_uri: this.clientMetadata.redirect_uris[0], - code_challenge: pkce?.challenge, - code_challenge_method: pkce?.method, - nonce, - state, - login_hint: did || undefined, - response_mode: this.responseMode, - response_type: - this.responseType != null && - metadata.response_types_supported?.includes(this.responseType) - ? this.responseType - : 'code', - - display: options?.display, - id_token_hint: options?.id_token_hint, - max_age: options?.max_age, // this.clientMetadata.default_max_age - prompt: options?.prompt, - scope: options?.scope - ?.split(' ') - .filter(s => metadata.scopes_supported?.includes(s)) - .join(' '), - ui_locales: options?.ui_locales, - } - - if (metadata.pushed_authorization_request_endpoint) { - const server = await this.serverFactory.fromMetadata(metadata, dpopKey) - const {json} = await server.request( - 'pushed_authorization_request', - parameters, - ) - - const authorizationUrl = new URL(metadata.authorization_endpoint) - authorizationUrl.searchParams.set( - 'client_id', - this.clientMetadata.client_id, - ) - authorizationUrl.searchParams.set('request_uri', json.request_uri) - return authorizationUrl - } else if (metadata.require_pushed_authorization_requests) { - throw new Error( - 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available', - ) - } else { - const authorizationUrl = new URL(metadata.authorization_endpoint) - for (const [key, value] of Object.entries(parameters)) { - if (value) authorizationUrl.searchParams.set(key, String(value)) - } - - // Length of the URL that will be sent to the server - const urlLength = - authorizationUrl.pathname.length + authorizationUrl.search.length - if (urlLength < 2048) { - return authorizationUrl - } else if (!metadata.pushed_authorization_request_endpoint) { - throw new Error('Login URL too long') - } - } - - throw new Error( - 'Server does not support pushed authorization requests (PAR)', - ) - } - - async callback(params: URLSearchParams): Promise<{ - client: OAuthClient - state?: string - }> { - // TODO: better errors - - const responseJwt = params.get('response') - if (responseJwt != null) { - // https://openid.net/specs/oauth-v2-jarm.html - throw new OAuthCallbackError(params, 'JARM not supported') - } - - const issuerParam = params.get('iss') - const stateParam = params.get('state') - const errorParam = params.get('error') - const codeParam = params.get('code') - - if (!stateParam) { - throw new OAuthCallbackError(params, 'Missing "state" parameter') - } - const stateData = await this.stateStore.get(stateParam) - if (stateData) { - // Prevent any kind of replay - await this.stateStore.del(stateParam) - } else { - throw new OAuthCallbackError(params, 'Invalid state') - } - - try { - if (errorParam != null) { - throw new OAuthCallbackError(params, undefined, stateData.appState) - } - - if (!codeParam) { - throw new OAuthCallbackError( - params, - 'Missing "code" query param', - stateData.appState, - ) - } - - const server = await this.serverFactory.fromIssuer( - stateData.iss, - stateData.dpopKey, - ) - - if (issuerParam != null) { - if (!server.serverMetadata.issuer) { - throw new OAuthCallbackError( - params, - 'Issuer not found in metadata', - stateData.appState, - ) - } - if (server.serverMetadata.issuer !== issuerParam) { - throw new OAuthCallbackError( - params, - 'Issuer mismatch', - stateData.appState, - ) - } - } else if ( - server.serverMetadata.authorization_response_iss_parameter_supported - ) { - throw new OAuthCallbackError( - params, - 'iss missing from the response', - stateData.appState, - ) - } - - const tokenSet = await server.exchangeCode(codeParam, stateData.verifier) - try { - if (tokenSet.id_token) { - await this.serverFactory.crypto.validateIdTokenClaims( - tokenSet.id_token, - stateParam, - stateData.nonce, - codeParam, - tokenSet.access_token, - ) - } - - const sessionId = await this.serverFactory.crypto.generateNonce(4) - - await this.sessionGetter.setStored(sessionId, { - dpopKey: stateData.dpopKey, - tokenSet, - }) - - const client = this.createClient(server, sessionId) - - return {client, state: stateData.appState} - } catch (err) { - await server.revoke(tokenSet.access_token) - - throw err - } - } catch (err) { - // Make sure, whatever the underlying error, that the appState is - // available in the calling code - throw OAuthCallbackError.from(err, params, stateData.appState) - } - } - - /** - * Build a client from a stored session. This will refresh the token only if - * needed (about to expire) by default. - * - * @param refresh See {@link SessionGetter.getSession} - */ - async restore(sessionId: string, refresh?: boolean): Promise { - const {dpopKey, tokenSet} = await this.sessionGetter.getSession( - sessionId, - refresh, - ) - - const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey) - - return this.createClient(server, sessionId) - } - - async revoke(sessionId: string) { - const {dpopKey, tokenSet} = await this.sessionGetter.get(sessionId, { - allowStale: true, - }) - - const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey) - - await server.revoke(tokenSet.access_token) - await this.sessionGetter.delStored(sessionId) - } - - createClient(server: OAuthServer, sessionId: string): OAuthClient { - return new OAuthClient(server, sessionId, this.sessionGetter) - } -} diff --git a/src/oauth-client-temp/client/oauth-client.ts b/src/oauth-client-temp/client/oauth-client.ts deleted file mode 100644 index aee2d9d81b..0000000000 --- a/src/oauth-client-temp/client/oauth-client.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {JwtPayload, unsafeDecodeJwt} from '#/oauth-client-temp/jwk' -import {OAuthServer, TokenSet} from './oauth-server' -import {SessionGetter} from './session-getter' - -export class OAuthClient { - constructor( - private readonly server: OAuthServer, - public readonly sessionId: string, - private readonly sessionGetter: SessionGetter, - ) {} - - /** - * @param refresh See {@link SessionGetter.getSession} - */ - async getTokenSet(refresh?: boolean): Promise { - const {tokenSet} = await this.sessionGetter.getSession( - this.sessionId, - refresh, - ) - return tokenSet - } - - async getUserinfo(): Promise<{ - userinfo?: JwtPayload - expired?: boolean - scope?: string - iss: string - aud: string - sub: string - }> { - const tokenSet = await this.getTokenSet() - - return { - userinfo: tokenSet.id_token - ? unsafeDecodeJwt(tokenSet.id_token).payload - : undefined, - expired: - tokenSet.expires_at == null - ? undefined - : tokenSet.expires_at < Date.now() - 5e3, - scope: tokenSet.scope, - iss: tokenSet.iss, - aud: tokenSet.aud, - sub: tokenSet.sub, - } - } - - async signOut() { - try { - const tokenSet = await this.getTokenSet(false) - await this.server.revoke(tokenSet.access_token) - } finally { - await this.sessionGetter.delStored(this.sessionId) - } - } - - async request( - pathname: string, - init?: RequestInit, - refreshCredentials?: boolean, - ): Promise { - const tokenSet = await this.getTokenSet(refreshCredentials) - const headers = new Headers(init?.headers) - headers.set( - 'Authorization', - `${tokenSet.token_type} ${tokenSet.access_token}`, - ) - const request = new Request(new URL(pathname, tokenSet.aud), { - ...init, - headers, - }) - - return this.server.dpopFetch(request).catch(err => { - if (!refreshCredentials && isTokenExpiredError(err)) { - return this.request(pathname, init, true) - } - - throw err - }) - } -} - -/** - * @todo Actually implement this - */ -function isTokenExpiredError(_err: unknown) { - // TODO: Detect access_token expired 401 - return false -} diff --git a/src/oauth-client-temp/client/oauth-database.ts b/src/oauth-client-temp/client/oauth-database.ts deleted file mode 100644 index 026a04af75..0000000000 --- a/src/oauth-client-temp/client/oauth-database.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {DidDocument} from '@atproto/did' -import {ResolvedHandle} from '@atproto/handle-resolver' -import {OAuthServerMetadata} from '@atproto/oauth-server-metadata' -import IsomorphicOAuthServerMetadataResolver from '@atproto/oauth-server-metadata-resolver' - -import {CryptoKey} from '#/oauth-client-temp/client/key' diff --git a/src/oauth-client-temp/client/oauth-resolver.ts b/src/oauth-client-temp/client/oauth-resolver.ts deleted file mode 100644 index 4ca5798435..0000000000 --- a/src/oauth-client-temp/client/oauth-resolver.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {IdentityResolver, ResolvedIdentity} from '@atproto/identity-resolver' -import { - OAuthServerMetadata, - OAuthServerMetadataResolver, -} from '@atproto/oauth-server-metadata-resolver' - -export class OAuthResolver { - constructor( - readonly metadataResolver: OAuthServerMetadataResolver, - readonly identityResolver: IdentityResolver, - ) {} - - public async resolve(input: string): Promise< - Partial & { - url: URL - metadata: OAuthServerMetadata - } - > { - const identity = /^https?:\/\//.test(input) - ? // Allow using a PDS url directly as login input (e.g. when the handle does not resolve to a DID) - {url: new URL(input)} - : await this.identityResolver.resolve(input, 'AtprotoPersonalDataServer') - - const metadata = await this.metadataResolver.resolve(identity.url.origin) - - return {...identity, metadata} - } -} diff --git a/src/oauth-client-temp/client/oauth-server-factory.ts b/src/oauth-client-temp/client/oauth-server-factory.ts deleted file mode 100644 index 265dda9c39..0000000000 --- a/src/oauth-client-temp/client/oauth-server-factory.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {GenericStore, MemoryStore} from '@atproto/caching' -import {Fetch} from '@atproto/fetch' -import {IdentityResolver} from '@atproto/identity-resolver' -import {OAuthClientMetadata} from '@atproto/oauth-client-metadata' -import {OAuthServerMetadata} from '@atproto/oauth-server-metadata' -import {OAuthServerMetadataResolver} from '@atproto/oauth-server-metadata-resolver' - -import {Key, Keyset} from '#/oauth-client-temp/jwk' -import {CryptoImplementation} from './crypto-implementation' -import {CryptoWrapper} from './crypto-wrapper' -import {OAuthResolver} from './oauth-resolver' -import {OAuthServer} from './oauth-server' -import {OAuthClientMetadataId} from './oauth-types' -import {validateClientMetadata} from './validate-client-metadata' - -export type OAuthServerFactoryOptions = { - clientMetadata: OAuthClientMetadata - metadataResolver: OAuthServerMetadataResolver - cryptoImplementation: CryptoImplementation - identityResolver: IdentityResolver - fetch?: Fetch - keyset?: Keyset - dpopNonceCache?: GenericStore -} - -export class OAuthServerFactory { - readonly clientMetadata: OAuthClientMetadataId - readonly metadataResolver: OAuthServerMetadataResolver - readonly crypto: CryptoWrapper - readonly resolver: OAuthResolver - readonly fetch: Fetch - readonly keyset?: Keyset - readonly dpopNonceCache: GenericStore - - constructor({ - metadataResolver, - identityResolver, - clientMetadata, - cryptoImplementation, - keyset, - fetch = globalThis.fetch, - dpopNonceCache = new MemoryStore({ - ttl: 60e3, - max: 100, - }), - }: OAuthServerFactoryOptions) { - validateClientMetadata(clientMetadata, keyset) - - if (!clientMetadata.client_id) { - throw new TypeError('A client_id property must be specified') - } - - this.clientMetadata = clientMetadata - this.metadataResolver = metadataResolver - this.keyset = keyset - this.fetch = fetch - this.dpopNonceCache = dpopNonceCache - - this.crypto = new CryptoWrapper(cryptoImplementation) - this.resolver = new OAuthResolver(metadataResolver, identityResolver) - } - - async fromIssuer(issuer: string, dpopKey: Key) { - const {origin} = new URL(issuer) - const serverMetadata = await this.metadataResolver.resolve(origin) - return this.fromMetadata(serverMetadata, dpopKey) - } - - async fromMetadata(serverMetadata: OAuthServerMetadata, dpopKey: Key) { - return new OAuthServer( - dpopKey, - serverMetadata, - this.clientMetadata, - this.dpopNonceCache, - this.resolver, - this.crypto, - this.keyset, - this.fetch, - ) - } -} diff --git a/src/oauth-client-temp/client/oauth-server.ts b/src/oauth-client-temp/client/oauth-server.ts deleted file mode 100644 index fdae49a451..0000000000 --- a/src/oauth-client-temp/client/oauth-server.ts +++ /dev/null @@ -1,287 +0,0 @@ -import {GenericStore} from '@atproto/caching' -import { - Fetch, - fetchFailureHandler, - fetchJsonProcessor, - fetchOkProcessor, -} from '@atproto/fetch' -import {OAuthClientMetadata} from '@atproto/oauth-client-metadata' -import {OAuthServerMetadata} from '@atproto/oauth-server-metadata' - -import {dpopFetchWrapper} from '#/oauth-client-temp/fetch-dpop' -import {Jwt, Key, Keyset} from '#/oauth-client-temp/jwk' -import {FALLBACK_ALG} from './constants' -import {CryptoWrapper} from './crypto-wrapper' -import {OAuthResolver} from './oauth-resolver' -import { - OAuthEndpointName, - OAuthTokenResponse, - OAuthTokenType, -} from './oauth-types' - -export type TokenSet = { - iss: string - sub: string - aud: string - scope?: string - - id_token?: Jwt - refresh_token?: string - access_token: string - token_type: OAuthTokenType - expires_at?: number -} - -export class OAuthServer { - readonly dpopFetch: (request: Request) => Promise - - constructor( - readonly dpopKey: Key, - readonly serverMetadata: OAuthServerMetadata, - readonly clientMetadata: OAuthClientMetadata & {client_id: string}, - readonly dpopNonceCache: GenericStore, - readonly resolver: OAuthResolver, - readonly crypto: CryptoWrapper, - readonly keyset?: Keyset, - fetch?: Fetch, - ) { - const dpopFetch = dpopFetchWrapper({ - fetch, - iss: this.clientMetadata.client_id, - key: dpopKey, - alg: negotiateAlg( - dpopKey, - serverMetadata.dpop_signing_alg_values_supported, - ), - sha256: async v => crypto.sha256(v), - nonceCache: dpopNonceCache, - }) - - this.dpopFetch = request => dpopFetch(request).catch(fetchFailureHandler) - } - - async revoke(token: string) { - try { - await this.request('revocation', {token}) - } catch { - // Don't care - } - } - - async exchangeCode(code: string, verifier?: string): Promise { - const {json: tokenResponse} = await this.request('token', { - grant_type: 'authorization_code', - redirect_uri: this.clientMetadata.redirect_uris[0]!, - code, - code_verifier: verifier, - }) - - try { - if (!tokenResponse.sub) { - throw new TypeError(`Missing "sub" in token response`) - } - - // VERY IMPORTANT ! - const resolved = await this.checkSubIssuer(tokenResponse.sub) - - return { - sub: tokenResponse.sub, - aud: resolved.url.href, - iss: resolved.metadata.issuer, - - scope: tokenResponse.scope, - id_token: tokenResponse.id_token, - refresh_token: tokenResponse.refresh_token, - access_token: tokenResponse.access_token, - token_type: tokenResponse.token_type ?? 'Bearer', - expires_at: - typeof tokenResponse.expires_in === 'number' - ? Date.now() + tokenResponse.expires_in * 1000 - : undefined, - } - } catch (err) { - await this.revoke(tokenResponse.access_token) - - throw err - } - } - - async refresh(tokenSet: TokenSet): Promise { - if (!tokenSet.refresh_token) { - throw new Error('No refresh token available') - } - - const {json: tokenResponse} = await this.request('token', { - grant_type: 'refresh_token', - refresh_token: tokenSet.refresh_token, - }) - - try { - if (tokenSet.sub !== tokenResponse.sub) { - throw new TypeError(`Unexpected "sub" in token response`) - } - if (tokenSet.iss !== this.serverMetadata.issuer) { - throw new TypeError('Issuer mismatch') - } - - // VERY IMPORTANT ! - const resolved = await this.checkSubIssuer(tokenResponse.sub) - - return { - sub: tokenResponse.sub, - aud: resolved.url.href, - iss: resolved.metadata.issuer, - - id_token: tokenResponse.id_token, - refresh_token: tokenResponse.refresh_token, - access_token: tokenResponse.access_token, - token_type: tokenResponse.token_type ?? 'Bearer', - expires_at: Date.now() + (tokenResponse.expires_in ?? 60) * 1000, - } - } catch (err) { - await this.revoke(tokenResponse.access_token) - - throw err - } - } - - /** - * Whenever an OAuth token response is received, we **MUST** verify that the - * "sub" is a DID, whose issuer authority is indeed the server we just - * obtained credentials from. This check is a critical step to actually be - * able to use the "sub" (DID) as being the actual user's identifier. - */ - protected async checkSubIssuer(sub: string) { - const resolved = await this.resolver.resolve(sub) - if (resolved.metadata.issuer !== this.serverMetadata.issuer) { - // Maybe the user switched PDS. - throw new TypeError('Issuer mismatch') - } - return resolved - } - - async request( - endpoint: E, - payload: Record, - ) { - const url = this.serverMetadata[`${endpoint}_endpoint`] - if (!url) throw new Error(`No ${endpoint} endpoint available`) - const auth = await this.buildClientAuth(endpoint) - - const request = new Request(url, { - method: 'POST', - headers: {...auth.headers, 'Content-Type': 'application/json'}, - body: JSON.stringify({...payload, ...auth.payload}), - }) - - const response = await this.dpopFetch(request) - .then(fetchOkProcessor()) - .then( - fetchJsonProcessor< - E extends 'pushed_authorization_request' - ? {request_uri: string} - : E extends 'token' - ? OAuthTokenResponse - : unknown - >(), - ) - - // TODO: validate using zod ? - if (endpoint === 'token') { - if (!response.json.access_token) { - throw new TypeError('No access token in token response') - } - } - - return response - } - - async buildClientAuth(endpoint: OAuthEndpointName): Promise<{ - headers?: Record - payload: - | { - client_id: string - } - | { - client_id: string - client_assertion_type: string - client_assertion: string - } - }> { - const methodSupported = - this.serverMetadata[`${endpoint}_endpoint_auth_methods_supported`] || - this.serverMetadata.token_endpoint_auth_methods_supported - - const method = - this.clientMetadata[`${endpoint}_endpoint_auth_method`] || - this.clientMetadata.token_endpoint_auth_method - - if ( - method === 'private_key_jwt' || - (this.keyset && - !method && - (methodSupported?.includes('private_key_jwt') ?? false)) - ) { - if (!this.keyset) throw new Error('No keyset available') - - try { - const alg = - this.serverMetadata[ - `${endpoint}_endpoint_auth_signing_alg_values_supported` - ] ?? - this.serverMetadata - .token_endpoint_auth_signing_alg_values_supported ?? - FALLBACK_ALG - - // If jwks is defined, make sure to only sign using a key that exists in - // the jwks. If jwks_uri is defined, we can't be sure that the key we're - // looking for is in there so we will just assume it is. - const kid = this.clientMetadata.jwks?.keys - .map(({kid}) => kid) - .filter((v): v is string => !!v) - - return { - payload: { - client_id: this.clientMetadata.client_id, - client_assertion_type: - 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - client_assertion: await this.keyset.sign( - {alg, kid}, - { - iss: this.clientMetadata.client_id, - sub: this.clientMetadata.client_id, - aud: this.serverMetadata.issuer, - jti: await this.crypto.generateNonce(), - iat: Math.floor(Date.now() / 1000), - }, - ), - }, - } - } catch (err) { - if (method === 'private_key_jwt') throw err - - // Else try next method - } - } - - if ( - method === 'none' || - (!method && (methodSupported?.includes('none') ?? true)) - ) { - return { - payload: { - client_id: this.clientMetadata.client_id, - }, - } - } - - throw new Error(`Unsupported ${endpoint} authentication method`) - } -} - -function negotiateAlg(key: Key, supportedAlgs: string[] | undefined): string { - const alg = key.algorithms.find(a => supportedAlgs?.includes(a) ?? true) - if (alg) return alg - - throw new Error('Key does not match any alg supported by the server') -} diff --git a/src/oauth-client-temp/client/oauth-types.ts b/src/oauth-client-temp/client/oauth-types.ts deleted file mode 100644 index 922abf9067..0000000000 --- a/src/oauth-client-temp/client/oauth-types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {OAuthClientMetadata} from '@atproto/oauth-client-metadata' - -import {Jwt} from '#/oauth-client-temp/jwk' - -export type OAuthResponseMode = 'query' | 'fragment' | 'form_post' -export type OAuthResponseType = 'code' | 'code id_token' - -export type OAuthEndpointName = - | 'token' - | 'revocation' - | 'introspection' - | 'pushed_authorization_request' - -export type OAuthTokenType = 'Bearer' | 'DPoP' - -export type OAuthAuthorizeOptions = { - display?: 'page' | 'popup' | 'touch' | 'wap' - id_token_hint?: string - max_age?: number - prompt?: 'login' | 'none' | 'consent' | 'select_account' - scope?: string - state?: string - ui_locales?: string -} - -export type OAuthTokenResponse = { - issuer?: string - sub?: string - scope?: string - id_token?: Jwt - refresh_token?: string - access_token: string - token_type?: OAuthTokenType - expires_in?: number -} - -export type OAuthClientMetadataId = OAuthClientMetadata & {client_id: string} diff --git a/src/oauth-client-temp/client/session-getter.ts b/src/oauth-client-temp/client/session-getter.ts deleted file mode 100644 index c2889c9673..0000000000 --- a/src/oauth-client-temp/client/session-getter.ts +++ /dev/null @@ -1,139 +0,0 @@ -import {CachedGetter, GenericStore} from '@atproto/caching' -import {FetchResponseError} from '@atproto/fetch' - -import {Key} from '#/oauth-client-temp/jwk' -import {TokenSet} from './oauth-server' -import {OAuthServerFactory} from './oauth-server-factory' - -export type Session = { - dpopKey: Key - tokenSet: TokenSet -} - -/** - * There are several advantages to wrapping the sessionStore in a (single) - * CachedGetter, the main of which is that the cached getter will ensure that at - * most one fresh call is ever being made. Another advantage, is that it - * contains the logic for reading from the cache which, if the cache is based on - * localStorage/indexedDB, will sync across multiple tabs (for a given - * sessionId). - */ -export class SessionGetter extends CachedGetter { - constructor( - sessionStore: GenericStore, - serverFactory: OAuthServerFactory, - ) { - super( - async (sessionId, options, storedSession) => { - // There needs to be a previous session to be able to refresh - if (storedSession === undefined) { - throw new Error('The session was revoked') - } - - // Since refresh tokens can only be used once, we might run into - // concurrency issues if multiple tabs/instances are trying to refresh - // the same token. The chances of this happening when multiple instances - // are started simultaneously is reduced by randomizing the expiry time - // (see isStale() bellow). Even so, There still exist chances that - // multiple tabs will try to refresh the token at the same time. The - // best solution would be to use a mutex/lock to ensure that only one - // instance is refreshing the token at a time. A simpler workaround is - // to check if the value stored in the session store is the same as the - // one in memory. If it isn't, then another instance has already - // refreshed the token. - - const {tokenSet, dpopKey} = storedSession - const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey) - const newTokenSet = await server.refresh(tokenSet).catch(async err => { - if (await isRefreshDeniedError(err)) { - // Allow some time for the concurrent request to be stored before - // we try to get it. - await new Promise(r => setTimeout(r, 500)) - - const stored = await this.getStored(sessionId) - if (stored !== undefined) { - if ( - stored.tokenSet.access_token !== tokenSet.access_token || - stored.tokenSet.refresh_token !== tokenSet.refresh_token - ) { - // A concurrent refresh occurred. Pretend this one succeeded. - return stored.tokenSet - } else { - // The session data will be deleted from the sessionStore by - // the "deleteOnError" callback. - } - } - } - - throw err - }) - return {...storedSession, tokenSet: newTokenSet} - }, - sessionStore, - { - isStale: (sessionId, {tokenSet}) => { - return ( - tokenSet.expires_at != null && - tokenSet.expires_at < - Date.now() + - // Add some lee way to ensure the token is not expired when it - // reaches the server. - 30e3 + - // Add some randomness to prevent all instances from trying to - // refreshing at the exact same time, when they are started at - // the same time. - 60e3 * Math.random() - ) - }, - onStoreError: async (err, sessionId, {tokenSet, dpopKey}) => { - // If the token data cannot be stored, let's revoke it - const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey) - await server.revoke(tokenSet.access_token) - throw err - }, - deleteOnError: async (err, sessionId, {tokenSet}) => { - // Not possible to refresh without a refresh token - if (!tokenSet.refresh_token) return true - - // If fetching a refresh token fails because they are no longer valid, - // delete the session from the sessionStore. - if (await isRefreshDeniedError(err)) return true - - // Unknown cause, keep the session in the store - return false - }, - }, - ) - } - - /** - * @param refresh When `true`, the credentials will be refreshed even if they - * are not expired. When `false`, the credentials will not be refreshed even - * if they are expired. When `undefined`, the credentials will be refreshed - * if, and only if, they are (about to be) expired. Defaults to `undefined`. - */ - async getSession(sessionId: string, refresh?: boolean) { - return this.get(sessionId, { - noCache: refresh === true, - allowStale: refresh === false, - }) - } -} - -async function isRefreshDeniedError(err: unknown) { - if (err instanceof FetchResponseError && err.statusCode === 400) { - if (err.response?.bodyUsed === false) { - try { - const json = await err.response.clone().json() - return ( - json.error === 'invalid_request' && - json.error_description === 'Invalid refresh token' - ) - } catch { - // falls through - } - } - } - - return false -} diff --git a/src/oauth-client-temp/client/util.ts b/src/oauth-client-temp/client/util.ts deleted file mode 100644 index 5e238ac3b7..0000000000 --- a/src/oauth-client-temp/client/util.ts +++ /dev/null @@ -1,160 +0,0 @@ -export type JWSAlgorithm = - // HMAC - | 'HS256' - | 'HS384' - | 'HS512' - // RSA - | 'PS256' - | 'PS384' - | 'PS512' - | 'RS256' - | 'RS384' - | 'RS512' - // EC - | 'ES256' - | 'ES256K' - | 'ES384' - | 'ES512' - // OKP - | 'EdDSA' - -// TODO REVIEW POLYFILL -// @ts-ignore Polyfilled -export type SubtleAlgorithm = RsaHashedKeyGenParams | EcKeyGenParams - -export function toSubtleAlgorithm( - alg: string, - crv?: string, - options?: {modulusLength?: number}, -): SubtleAlgorithm { - switch (alg) { - case 'PS256': - case 'PS384': - case 'PS512': - return { - name: 'RSA-PSS', - hash: `SHA-${alg.slice(-3) as '256' | '384' | '512'}`, - modulusLength: options?.modulusLength ?? 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - } - case 'RS256': - case 'RS384': - case 'RS512': - return { - name: 'RSASSA-PKCS1-v1_5', - hash: `SHA-${alg.slice(-3) as '256' | '384' | '512'}`, - modulusLength: options?.modulusLength ?? 2048, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - } - case 'ES256': - case 'ES384': - return { - name: 'ECDSA', - namedCurve: `P-${alg.slice(-3) as '256' | '384'}`, - } - case 'ES512': - return { - name: 'ECDSA', - namedCurve: 'P-521', - } - default: - // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773 - - throw new TypeError(`Unsupported alg "${alg}"`) - } -} - -// TODO REVIEW POLYFILL -// @ts-ignore Polyfilled -export function fromSubtleAlgorithm(algorithm: KeyAlgorithm): JWSAlgorithm { - switch (algorithm.name) { - case 'RSA-PSS': - case 'RSASSA-PKCS1-v1_5': { - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - const hash = (algorithm).hash.name - switch (hash) { - case 'SHA-256': - case 'SHA-384': - case 'SHA-512': { - const prefix = algorithm.name === 'RSA-PSS' ? 'PS' : 'RS' - return `${prefix}${hash.slice(-3) as '256' | '384' | '512'}` - } - default: - throw new TypeError('unsupported RsaHashedKeyAlgorithm hash') - } - } - case 'ECDSA': { - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - const namedCurve = (algorithm).namedCurve - switch (namedCurve) { - case 'P-256': - case 'P-384': - case 'P-512': - return `ES${namedCurve.slice(-3) as '256' | '384' | '512'}` - case 'P-521': - return 'ES512' - default: - throw new TypeError('unsupported EcKeyAlgorithm namedCurve') - } - } - case 'Ed448': - case 'Ed25519': - return 'EdDSA' - default: - // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773 - - throw new TypeError(`Unexpected algorithm "${algorithm.name}"`) - } -} - -export function isSignatureKeyPair( - v: unknown, - extractable?: boolean, - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled -): v is CryptoKeyPair { - return ( - typeof v === 'object' && - v !== null && - 'privateKey' in v && - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - v.privateKey instanceof CryptoKey && - v.privateKey.type === 'private' && - (extractable == null || v.privateKey.extractable === extractable) && - v.privateKey.usages.includes('sign') && - 'publicKey' in v && - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - v.publicKey instanceof CryptoKey && - v.publicKey.type === 'public' && - v.publicKey.extractable === true && - v.publicKey.usages.includes('verify') - ) -} - -export async function generateKeypair( - algs: string[], - extractable = false, - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled -): Promise { - const errors: unknown[] = [] - for (const alg of algs) { - try { - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - return await crypto.subtle.generateKey( - toSubtleAlgorithm(alg), - extractable, - ['sign', 'verify'], - ) - } catch (err) { - errors.push(err) - } - } - - throw new AggregateError(errors, 'Failed to generate keypair') -} diff --git a/src/oauth-client-temp/client/validate-client-metadata.ts b/src/oauth-client-temp/client/validate-client-metadata.ts deleted file mode 100644 index fdc3aece4d..0000000000 --- a/src/oauth-client-temp/client/validate-client-metadata.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {OAuthClientMetadata} from '@atproto/oauth-client-metadata' - -import {Keyset} from '#/oauth-client-temp/jwk' - -export function validateClientMetadata( - metadata: OAuthClientMetadata, - keyset?: Keyset, -): asserts metadata is OAuthClientMetadata & {client_id: string} { - if (!metadata.client_id) { - throw new TypeError('client_id must be provided') - } - - const url = new URL(metadata.client_id) - if (url.pathname !== '/') { - throw new TypeError('origin must be a URL root') - } - if (url.username || url.password) { - throw new TypeError('client_id URI must not contain a username or password') - } - if (url.search || url.hash) { - throw new TypeError('client_id URI must not contain a query or fragment') - } - if (url.href !== metadata.client_id) { - throw new TypeError('client_id URI must be a normalized URL') - } - - if ( - url.hostname === 'localhost' || - url.hostname === '[::1]' || - url.hostname === '127.0.0.1' - ) { - if (url.protocol !== 'http:' || url.port) { - throw new TypeError('loopback clients must use "http:" and port "80"') - } - } - - if (metadata.client_uri && metadata.client_uri !== metadata.client_id) { - throw new TypeError('client_uri must match client_id') - } - - if (!metadata.redirect_uris.length) { - throw new TypeError('At least one redirect_uri must be provided') - } - for (const u of metadata.redirect_uris) { - const redirectUrl = new URL(u) - // Loopback redirect_uris require special handling - if ( - redirectUrl.hostname === 'localhost' || - redirectUrl.hostname === '[::1]' || - redirectUrl.hostname === '127.0.0.1' - ) { - if (redirectUrl.protocol !== 'http:') { - throw new TypeError('loopback redirect_uris must use "http:"') - } - } else { - // Not a loopback client - if (redirectUrl.origin !== url.origin) { - throw new TypeError('redirect_uris must have the same origin') - } - } - } - - for (const endpoint of [ - 'token', - 'revocation', - 'introspection', - 'pushed_authorization_request', - ] as const) { - const method = metadata[`${endpoint}_endpoint_auth_method`] - if (method && method !== 'none') { - if (!keyset) { - throw new TypeError(`Keyset is required for ${method} method`) - } - if (!metadata[`${endpoint}_endpoint_auth_signing_alg`]) { - throw new TypeError( - `${endpoint}_endpoint_auth_signing_alg must be provided`, - ) - } - } - } -} diff --git a/src/oauth-client-temp/disposable-polyfill/index.ts b/src/oauth-client-temp/disposable-polyfill/index.ts deleted file mode 100644 index ddb9073b16..0000000000 --- a/src/oauth-client-temp/disposable-polyfill/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Code compiled with tsc supports "using" and "await using" syntax. This -// features is supported by downleveling the code to ES2017. The downleveling -// relies on `Symbol.dispose` and `Symbol.asyncDispose` symbols. These symbols -// might not be available in all environments. This package provides a polyfill -// for these symbols. - -// @ts-expect-error -Symbol.dispose ??= Symbol('@@dispose') -// @ts-expect-error -Symbol.asyncDispose ??= Symbol('@@asyncDispose') diff --git a/src/oauth-client-temp/fetch-dpop/index.ts b/src/oauth-client-temp/fetch-dpop/index.ts deleted file mode 100644 index eb3f3e4a45..0000000000 --- a/src/oauth-client-temp/fetch-dpop/index.ts +++ /dev/null @@ -1,174 +0,0 @@ -import {GenericStore} from '@atproto/caching' -import {Fetch} from '@atproto/fetch' - -import {b64uEncode} from '#/oauth-client-temp/b64' -import {Key} from '#/oauth-client-temp/jwk' - -export function dpopFetchWrapper({ - key, - iss, - alg, - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - sha256 = typeof crypto !== 'undefined' && crypto.subtle != null - ? subtleSha256 - : undefined, - nonceCache, -}: { - key: Key - iss: string - alg?: string - sha256?: (input: string) => Promise - nonceCache?: GenericStore - fetch?: Fetch -}): Fetch { - if (!sha256) { - throw new Error( - `crypto.subtle is not available in this environment. Please provide a sha256 function.`, - ) - } - - return async function (request) { - return dpopFetch.call( - this, - request, - key, - iss, - alg, - sha256, - nonceCache, - fetch, - ) - } -} - -export async function dpopFetch( - this: ThisParameterType, - request: Request, - key: Key, - iss: string, - alg: string = key.alg || 'ES256', - sha256: (input: string) => string | PromiseLike = subtleSha256, - nonceCache?: GenericStore, - fetch = globalThis.fetch as Fetch, -): Promise { - const authorizationHeader = request.headers.get('Authorization') - const ath = authorizationHeader?.startsWith('DPoP ') - ? await sha256(authorizationHeader.slice(5)) - : undefined - - const {origin} = new URL(request.url) - - // Clone request for potential retry - const clonedRequest = request.clone() - - // Try with the previously known nonce - const oldNonce = await Promise.resolve() - .then(() => nonceCache?.get(origin)) - .catch(() => undefined) // Ignore cache.get errors - - request.headers.set( - 'DPoP', - await buildProof(key, alg, iss, request.method, request.url, oldNonce, ath), - ) - - const response = await fetch(request) - - const nonce = response.headers.get('DPoP-Nonce') - if (!nonce) return response - - // Store the fresh nonce for future requests - try { - await nonceCache?.set(origin, nonce) - } catch { - // Ignore cache.set errors - } - - if (!(await isUseDpopNonceError(response))) { - return response - } - - clonedRequest.headers.set( - 'DPoP', - await buildProof(key, alg, iss, request.method, request.url, nonce, ath), - ) - - return fetch(clonedRequest) -} - -async function buildProof( - key: Key, - alg: string, - iss: string, - htm: string, - htu: string, - nonce?: string, - ath?: string, -) { - if (!key.bareJwk) { - throw new Error('Only asymetric keys can be used as DPoP proofs') - } - - const now = Math.floor(Date.now() / 1e3) - - return key.createJwt( - { - alg, - typ: 'dpop+jwt', - jwk: key.bareJwk, - }, - { - iss, - iat: now, - exp: now + 10, - // Any collision will cause the request to be rejected by the server. no biggie. - jti: Math.random().toString(36).slice(2), - htm, - htu, - nonce, - ath, - }, - ) -} - -async function isUseDpopNonceError(response: Response): Promise { - if (response.status !== 400) { - return false - } - - const ct = response.headers.get('Content-Type') - const mime = ct?.split(';')[0]?.trim() - if (mime !== 'application/json') { - return false - } - - try { - const body = await response.clone().json() - return body?.error === 'use_dpop_nonce' - } catch { - return false - } -} - -function subtleSha256(input: string): Promise { - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - if (typeof crypto === 'undefined' || crypto.subtle == null) { - throw new Error( - `crypto.subtle is not available in this environment. Please provide a sha256 function.`, - ) - } - - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - return ( - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - crypto.subtle - // TODO REVIEW POLYFILL - // @ts-ignore Polyfilled - .digest('SHA-256', new TextEncoder().encode(input)) - // TODO OAUTH types - .then((digest: Iterable) => b64uEncode(new Uint8Array(digest))) - ) -} diff --git a/src/oauth-client-temp/identity-resolver/identity-resolver.ts b/src/oauth-client-temp/identity-resolver/identity-resolver.ts deleted file mode 100644 index 88e592ff2b..0000000000 --- a/src/oauth-client-temp/identity-resolver/identity-resolver.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DidResolver } from '@atproto/did' -import { - HandleResolver, - ResolvedHandle, - isResolvedHandle, -} from '@atproto/handle-resolver' -import { normalizeAndEnsureValidHandle } from '@atproto/syntax' - -export type ResolvedIdentity = { - did: NonNullable - url: URL -} - -export class IdentityResolver { - constructor( - readonly handleResolver: HandleResolver, - readonly didResolver: DidResolver<'plc' | 'web'>, - ) {} - - public async resolve( - input: string, - serviceType = 'AtprotoPersonalDataServer', - ): Promise { - const did = isResolvedHandle(input) - ? input // Already a did - : await this.handleResolver.resolve(normalizeAndEnsureValidHandle(input)) - if (!did) throw new Error(`Handle ${input} does not resolve to a DID`) - - const url = await this.didResolver.resolveServiceEndpoint(did, { - type: serviceType, - }) - - return { did, url } - } -} diff --git a/src/oauth-client-temp/identity-resolver/index.ts b/src/oauth-client-temp/identity-resolver/index.ts deleted file mode 100644 index bccd3ae900..0000000000 --- a/src/oauth-client-temp/identity-resolver/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './identity-resolver' -export * from './universal-identity-resolver' diff --git a/src/oauth-client-temp/identity-resolver/universal-identity-resolver.ts b/src/oauth-client-temp/identity-resolver/universal-identity-resolver.ts deleted file mode 100644 index a9201d93c2..0000000000 --- a/src/oauth-client-temp/identity-resolver/universal-identity-resolver.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - DidCache, - IsomorphicDidResolver, - IsomorphicDidResolverOptions, -} from '@atproto/did' -import {Fetch} from '@atproto/fetch' -import UniversalHandleResolver, { - HandleResolverCache, - UniversalHandleResolverOptions, -} from '@atproto/handle-resolver' - -import {IdentityResolver} from './identity-resolver' - -export type UniversalIdentityResolverOptions = { - fetch?: Fetch - - didCache?: DidCache - handleCache?: HandleResolverCache - - /** - * @see {@link IsomorphicDidResolverOptions.plcDirectoryUrl} - */ - plcDirectoryUrl?: IsomorphicDidResolverOptions['plcDirectoryUrl'] - - /** - * @see {@link UniversalHandleResolverOptions.atprotoLexiconUrl} - */ - atprotoLexiconUrl?: UniversalHandleResolverOptions['atprotoLexiconUrl'] -} - -export class UniversalIdentityResolver extends IdentityResolver { - static from({ - fetch = globalThis.fetch, - didCache, - handleCache, - plcDirectoryUrl, - atprotoLexiconUrl, - }: UniversalIdentityResolverOptions) { - return new this( - new UniversalHandleResolver({ - fetch, - cache: handleCache, - atprotoLexiconUrl, - }), - new IsomorphicDidResolver({ - fetch, // - cache: didCache, - plcDirectoryUrl, - }), - ) - } -} diff --git a/src/oauth-client-temp/indexed-db/db-index.web.ts b/src/oauth-client-temp/indexed-db/db-index.web.ts deleted file mode 100644 index b06fdd5844..0000000000 --- a/src/oauth-client-temp/indexed-db/db-index.web.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {ObjectStoreSchema} from './schema' -import {promisify} from './util' - -export class DbIndexWeb { - constructor(private idbIndex: IDBIndex) {} - - count(query?: IDBValidKey | IDBKeyRange) { - return promisify(this.idbIndex.count(query)) - } - - get(query: IDBValidKey | IDBKeyRange) { - return promisify(this.idbIndex.get(query)) - } - - getKey(query: IDBValidKey | IDBKeyRange) { - return promisify(this.idbIndex.getKey(query)) - } - - getAll(query?: IDBValidKey | IDBKeyRange | null, count?: number) { - return promisify(this.idbIndex.getAll(query, count)) - } - - getAllKeys(query?: IDBValidKey | IDBKeyRange | null, count?: number) { - return promisify(this.idbIndex.getAllKeys(query, count)) - } - - deleteAll(query?: IDBValidKey | IDBKeyRange | null): Promise { - return new Promise((resolve, reject) => { - const result = this.idbIndex.openCursor(query) - result.onsuccess = function (event) { - const cursor = (event as any).target.result as IDBCursorWithValue - if (cursor) { - cursor.delete() - cursor.continue() - } else { - resolve() - } - } - result.onerror = function (event) { - reject((event.target as any)?.error || new Error('Unexpected error')) - } - }) - } -} diff --git a/src/oauth-client-temp/indexed-db/db-object-store.web.ts b/src/oauth-client-temp/indexed-db/db-object-store.web.ts deleted file mode 100644 index 44c20a7004..0000000000 --- a/src/oauth-client-temp/indexed-db/db-object-store.web.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {DbIndexWeb} from './db-index.web' -import {ObjectStoreSchema} from './schema' -import {promisify} from './util' - -export class DbObjectStoreWeb { - constructor(private idbObjStore: IDBObjectStore) {} - - get name() { - return this.idbObjStore.name - } - - index(name: string) { - return new DbIndexWeb(this.idbObjStore.index(name)) - } - - get(key: IDBValidKey | IDBKeyRange) { - return promisify(this.idbObjStore.get(key)) - } - - getKey(query: IDBValidKey | IDBKeyRange) { - return promisify(this.idbObjStore.getKey(query)) - } - - getAll(query?: IDBValidKey | IDBKeyRange | null, count?: number) { - return promisify(this.idbObjStore.getAll(query, count)) - } - - getAllKeys(query?: IDBValidKey | IDBKeyRange | null, count?: number) { - return promisify(this.idbObjStore.getAllKeys(query, count)) - } - - add(value: Schema, key?: IDBValidKey) { - return promisify(this.idbObjStore.add(value, key)) - } - - put(value: Schema, key?: IDBValidKey) { - return promisify(this.idbObjStore.put(value, key)) - } - - delete(key: IDBValidKey | IDBKeyRange) { - return promisify(this.idbObjStore.delete(key)) - } - - clear() { - return promisify(this.idbObjStore.clear()) - } -} diff --git a/src/oauth-client-temp/indexed-db/db-transaction.web.ts b/src/oauth-client-temp/indexed-db/db-transaction.web.ts deleted file mode 100644 index 08fddc9afe..0000000000 --- a/src/oauth-client-temp/indexed-db/db-transaction.web.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {DbObjectStoreWeb} from './db-object-store.web' -import {DatabaseSchema} from './schema' - -export class DbTransactionWeb - implements Disposable -{ - #tx: IDBTransaction | null - - constructor(tx: IDBTransaction) { - this.#tx = tx - - const onAbort = () => { - cleanup() - } - const onComplete = () => { - cleanup() - } - const cleanup = () => { - this.#tx = null - tx.removeEventListener('abort', onAbort) - tx.removeEventListener('complete', onComplete) - } - tx.addEventListener('abort', onAbort) - tx.addEventListener('complete', onComplete) - } - - protected get tx(): IDBTransaction { - if (!this.#tx) throw new Error('Transaction already ended') - return this.#tx - } - - async abort() { - const {tx} = this - this.#tx = null - tx.abort() - } - - async commit() { - const {tx} = this - this.#tx = null - tx.commit?.() - } - - objectStore(name: T) { - const store = this.tx.objectStore(name) - return new DbObjectStoreWeb(store) - } - - [Symbol.dispose](): void { - if (this.#tx) this.commit() - } -} diff --git a/src/oauth-client-temp/indexed-db/db.ts b/src/oauth-client-temp/indexed-db/db.ts deleted file mode 100644 index 5d83361657..0000000000 --- a/src/oauth-client-temp/indexed-db/db.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {DatabaseSchema} from '#/oauth-client-temp/indexed-db/schema' - -export class Db implements Disposable { - static async open( - dbname: string, - migrations: ReadonlyArray<(db: IDBDatabase) => void>, - txOptions?: IDBTransactionOptions, - ) -} diff --git a/src/oauth-client-temp/indexed-db/db.web.ts b/src/oauth-client-temp/indexed-db/db.web.ts deleted file mode 100644 index a983b7f530..0000000000 --- a/src/oauth-client-temp/indexed-db/db.web.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {DbTransactionWeb} from './db-transaction.web' -import {DatabaseSchema} from './schema' - -export class Db implements Disposable { - static async open( - dbName: string, - migrations: ReadonlyArray<(db: IDBDatabase) => void>, - txOptions?: IDBTransactionOptions, - ) { - const db = await new Promise((resolve, reject) => { - const request = indexedDB.open(dbName, migrations.length) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result) - request.onupgradeneeded = ({oldVersion, newVersion}) => { - const db = request.result - try { - for ( - let version = oldVersion; - version < (newVersion ?? migrations.length); - ++version - ) { - const migration = migrations[version] - if (migration) migration(db) - else throw new Error(`Missing migration for version ${version}`) - } - } catch (err) { - db.close() - reject(err) - } - } - }) - - return new DbWeb(db, txOptions) - } - - #db: null | IDBDatabase - - constructor( - db: IDBDatabase, - protected readonly txOptions?: IDBTransactionOptions, - ) { - this.#db = db - - const cleanup = () => { - this.#db = null - db.removeEventListener('versionchange', cleanup) - db.removeEventListener('close', cleanup) - db.close() // Can we call close on a "closed" database? - } - - db.addEventListener('versionchange', cleanup) - db.addEventListener('close', cleanup) - } - - protected get db(): IDBDatabase { - if (!this.#db) throw new Error('Database closed') - return this.#db - } - - get name() { - return this.db.name - } - - get objectStoreNames() { - return this.db.objectStoreNames - } - - get version() { - return this.db.version - } - - async transaction( - storeNames: T, - mode: IDBTransactionMode, - run: (tx: DbTransactionWeb>) => R | PromiseLike, - ): Promise { - return new Promise(async (resolve, reject) => { - try { - const tx = this.db.transaction(storeNames, mode, this.txOptions) - let result: {done: false} | {done: true; value: R} = {done: false} - - tx.oncomplete = () => { - if (result.done) resolve(result.value) - else reject(new Error('Transaction completed without result')) - } - tx.onerror = () => reject(tx.error) - tx.onabort = () => reject(tx.error || new Error('Transaction aborted')) - - try { - const value = await run(new DbTransactionWeb(tx)) - result = {done: true, value} - tx.commit() - } catch (err) { - tx.abort() - throw err - } - } catch (err) { - reject(err) - } - }) - } - - close() { - const {db} = this - this.#db = null - db.close() - } - - [Symbol.dispose]() { - if (this.#db) return this.close() - } -} diff --git a/src/oauth-client-temp/indexed-db/index.web.ts b/src/oauth-client-temp/indexed-db/index.web.ts deleted file mode 100644 index d1b6d4e0dd..0000000000 --- a/src/oauth-client-temp/indexed-db/index.web.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '#/oauth-client-temp/disposable-polyfill' - -export * from './db.web' -export * from './db.web' -export * from './db-index.web' -export * from './db-object-store.web' diff --git a/src/oauth-client-temp/indexed-db/schema.ts b/src/oauth-client-temp/indexed-db/schema.ts deleted file mode 100644 index f8736b2a19..0000000000 --- a/src/oauth-client-temp/indexed-db/schema.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type ObjectStoreSchema = NonNullable -export type DatabaseSchema = Record diff --git a/src/oauth-client-temp/indexed-db/util.web.ts b/src/oauth-client-temp/indexed-db/util.web.ts deleted file mode 100644 index 6e52b5919c..0000000000 --- a/src/oauth-client-temp/indexed-db/util.web.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function promisify(request: IDBRequest) { - const promise = new Promise((resolve, reject) => { - const cleanup = () => { - request.removeEventListener('success', success) - request.removeEventListener('error', error) - } - const success = () => { - resolve(request.result) - cleanup() - } - const error = () => { - reject(request.error) - cleanup() - } - request.addEventListener('success', success) - request.addEventListener('error', error) - }) - - return promise -} diff --git a/src/oauth-client-temp/jwk-jose/index.ts b/src/oauth-client-temp/jwk-jose/index.ts deleted file mode 100644 index 179625dd51..0000000000 --- a/src/oauth-client-temp/jwk-jose/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './jose-key' -export * from './jose-keyset' diff --git a/src/oauth-client-temp/jwk-jose/jose-key.ts b/src/oauth-client-temp/jwk-jose/jose-key.ts deleted file mode 100644 index baeb31ff2c..0000000000 --- a/src/oauth-client-temp/jwk-jose/jose-key.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - exportJWK, - importJWK, - importPKCS8, - JWK, - jwtVerify, - JWTVerifyOptions, - KeyLike, - SignJWT, -} from 'jose' - -import { - Jwk, - jwkSchema, - Jwt, - JwtHeader, - JwtPayload, - Key, - VerifyOptions, - VerifyPayload, - VerifyResult, -} from '#/oauth-client-temp/jwk' -import {either} from './util' - -export type Importable = string | KeyLike | Jwk - -export class JoseKey extends Key { - #keyObj?: KeyLike | Uint8Array - - protected async getKey() { - return (this.#keyObj ||= await importJWK(this.jwk as JWK)) - } - - async createJwt(header: JwtHeader, payload: JwtPayload) { - if (header.kid && header.kid !== this.kid) { - throw new TypeError( - `Invalid "kid" (${header.kid}) used to sign with key "${this.kid}"`, - ) - } - - if (!header.alg || !this.algorithms.includes(header.alg)) { - throw new TypeError( - `Invalid "alg" (${header.alg}) used to sign with key "${this.kid}"`, - ) - } - - const keyObj = await this.getKey() - return new SignJWT(payload) - .setProtectedHeader({...header, kid: this.kid}) - .sign(keyObj) as Promise - } - - async verifyJwt< - P extends VerifyPayload = JwtPayload, - C extends string = string, - >(token: Jwt, options?: VerifyOptions): Promise> { - const keyObj = await this.getKey() - const result = await jwtVerify(token, keyObj, { - ...options, - algorithms: this.algorithms, - } as JWTVerifyOptions) - return result as VerifyResult - } - - static async fromImportable( - input: Importable, - kid?: string, - ): Promise { - if (typeof input === 'string') { - // PKCS8 - if (input.startsWith('-----')) { - return this.fromPKCS8(input, kid) - } - - // Jwk (string) - if (input.startsWith('{')) { - return this.fromJWK(input, kid) - } - - throw new TypeError('Invalid input') - } - - if (typeof input === 'object') { - // Jwk - if ('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(pem: string, kid?: string): Promise { - const keyLike = await importPKCS8(pem, '', {extractable: true}) - return this.fromJWK(await exportJWK(keyLike), kid) - } - - 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' - - return new JoseKey({...jwk, kid, alg, use}) - } -} diff --git a/src/oauth-client-temp/jwk-jose/jose-keyset.ts b/src/oauth-client-temp/jwk-jose/jose-keyset.ts deleted file mode 100644 index 27baefcfda..0000000000 --- a/src/oauth-client-temp/jwk-jose/jose-keyset.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {Key, Keyset} from '#/oauth-client-temp/jwk' -import {Importable, JoseKey} from './jose-key' - -export class JoseKeyset extends Keyset { - static async fromImportables( - input: Record, - ) { - return new JoseKeyset( - await Promise.all( - Object.entries(input).map(([kid, secret]) => - secret instanceof Key ? secret : JoseKey.fromImportable(secret, kid), - ), - ), - ) - } -} diff --git a/src/oauth-client-temp/jwk-jose/util.ts b/src/oauth-client-temp/jwk-jose/util.ts deleted file mode 100644 index f75cdb6671..0000000000 --- a/src/oauth-client-temp/jwk-jose/util.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/src/oauth-client-temp/jwk/alg.ts b/src/oauth-client-temp/jwk/alg.ts deleted file mode 100644 index 8be3555018..0000000000 --- a/src/oauth-client-temp/jwk/alg.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {Jwk} from './jwk' - -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 deleted file mode 100644 index 79393d6eea..0000000000 --- a/src/oauth-client-temp/jwk/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './alg' -export * from './jwk' -export * from './jwks' -export * from './jwt' -export * from './jwt-decode' -export * from './jwt-verify' -export * from './key' -export * from './keyset' -export * from './util' diff --git a/src/oauth-client-temp/jwk/jwk.ts b/src/oauth-client-temp/jwk/jwk.ts deleted file mode 100644 index f74a2ef377..0000000000 --- a/src/oauth-client-temp/jwk/jwk.ts +++ /dev/null @@ -1,153 +0,0 @@ -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 deleted file mode 100644 index b1b333d986..0000000000 --- a/src/oauth-client-temp/jwk/jwks.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {z} from 'zod' - -import {jwkPubSchema, jwkSchema} from './jwk' - -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 deleted file mode 100644 index 7fc5f3ef3f..0000000000 --- a/src/oauth-client-temp/jwk/jwt-decode.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {b64uDecode} from '#/oauth-client-temp/b64' -import {JwtHeader, jwtHeaderSchema, JwtPayload, jwtPayloadSchema} from './jwt' -import {ui8ToString} from './util' - -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 deleted file mode 100644 index 3e05f60ae5..0000000000 --- a/src/oauth-client-temp/jwk/jwt-verify.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {JwtHeader, JwtPayload} from './jwt' -import {RequiredKey} from './util' - -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 deleted file mode 100644 index 51de57916c..0000000000 --- a/src/oauth-client-temp/jwk/jwt.ts +++ /dev/null @@ -1,172 +0,0 @@ -import {z} from 'zod' - -import {jwkPubSchema} from './jwk' - -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 deleted file mode 100644 index c9923f043e..0000000000 --- a/src/oauth-client-temp/jwk/key.ts +++ /dev/null @@ -1,95 +0,0 @@ -import {jwkAlgorithms} from './alg' -import {Jwk, jwkSchema} from './jwk' -import {Jwt, JwtHeader, JwtPayload} from './jwt' -import {VerifyOptions, VerifyPayload, VerifyResult} from './jwt-verify' -import {cachedGetter} from './util' - -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 deleted file mode 100644 index 09137aaba5..0000000000 --- a/src/oauth-client-temp/jwk/keyset.ts +++ /dev/null @@ -1,200 +0,0 @@ -import {Jwk} from './jwk' -import {Jwks} from './jwks' -import {Jwt, JwtHeader, JwtPayload} from './jwt' -import {unsafeDecodeJwt} from './jwt-decode' -import {VerifyOptions} from './jwt-verify' -import {Key} from './key' -import { - cachedGetter, - isDefined, - matchesAny, - Override, - preferredOrderCmp, -} from './util' - -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 deleted file mode 100644 index 12b5625cce..0000000000 --- a/src/oauth-client-temp/jwk/util.ts +++ /dev/null @@ -1,55 +0,0 @@ -// 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)