From 1461c0a34f671d5814bb93625a7646c3c9c0ee9c Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 9 Apr 2024 13:04:33 -0700 Subject: [PATCH] decent base --- package.json | 2 +- .../client/crypto-implementation.ts | 44 ++- .../client/crypto-wrapper.ts | 173 +++++++++++ src/oauth-client-temp/client/db.ts | 0 src/oauth-client-temp/client/db.web.ts | 0 src/oauth-client-temp/client/key.ts | 78 +++++ src/oauth-client-temp/client/keyset.ts | 0 .../client/oauth-callback-error.ts | 16 + .../client/oauth-client-factory.ts | 290 ++++++++++++++++++ src/oauth-client-temp/client/oauth-client.ts | 89 ++++++ .../client/oauth-database.ts | 6 + .../client/oauth-resolver.ts | 28 ++ .../client/oauth-server-factory.ts | 81 +++++ src/oauth-client-temp/client/oauth-server.ts | 287 +++++++++++++++++ src/oauth-client-temp/client/oauth-types.ts | 37 +++ .../client/session-getter.ts | 139 +++++++++ src/oauth-client-temp/client/util.ts | 160 ++++++++++ .../client/validate-client-metadata.ts | 81 +++++ .../disposable-polyfill/index.ts | 10 + src/oauth-client-temp/fetch-dpop/index.ts | 174 +++++++++++ .../identity-resolver/identity-resolver.ts | 35 +++ .../identity-resolver/index.ts | 2 + .../universal-identity-resolver.ts | 52 ++++ .../indexed-db/db-index.web.ts | 44 +++ .../indexed-db/db-object-store.web.ts | 47 +++ .../indexed-db/db-transaction.web.ts | 52 ++++ src/oauth-client-temp/indexed-db/db.ts | 9 + src/oauth-client-temp/indexed-db/db.web.ts | 113 +++++++ src/oauth-client-temp/indexed-db/index.web.ts | 6 + src/oauth-client-temp/indexed-db/schema.ts | 2 + src/oauth-client-temp/indexed-db/util.web.ts | 20 ++ src/oauth-client-temp/jwk-jose/index.ts | 2 + src/oauth-client-temp/jwk-jose/jose-key.ts | 115 +++++++ src/oauth-client-temp/jwk-jose/jose-keyset.ts | 16 + src/oauth-client-temp/jwk-jose/util.ts | 9 + src/oauth-client-temp/jwk/alg.ts | 4 +- src/oauth-client-temp/jwk/index.ts | 18 +- src/oauth-client-temp/jwk/jwks.ts | 4 +- src/oauth-client-temp/jwk/jwt-decode.ts | 16 +- src/oauth-client-temp/jwk/jwt-verify.ts | 4 +- src/oauth-client-temp/jwk/jwt.ts | 4 +- src/oauth-client-temp/jwk/key.ts | 22 +- src/oauth-client-temp/jwk/keyset.ts | 40 +-- src/screens/Login/hooks/useLogin.ts | 6 +- yarn.lock | 13 +- 45 files changed, 2276 insertions(+), 74 deletions(-) create mode 100644 src/oauth-client-temp/client/crypto-wrapper.ts create mode 100644 src/oauth-client-temp/client/db.ts create mode 100644 src/oauth-client-temp/client/db.web.ts create mode 100644 src/oauth-client-temp/client/key.ts create mode 100644 src/oauth-client-temp/client/keyset.ts create mode 100644 src/oauth-client-temp/client/oauth-callback-error.ts create mode 100644 src/oauth-client-temp/client/oauth-client-factory.ts create mode 100644 src/oauth-client-temp/client/oauth-client.ts create mode 100644 src/oauth-client-temp/client/oauth-database.ts create mode 100644 src/oauth-client-temp/client/oauth-resolver.ts create mode 100644 src/oauth-client-temp/client/oauth-server-factory.ts create mode 100644 src/oauth-client-temp/client/oauth-server.ts create mode 100644 src/oauth-client-temp/client/oauth-types.ts create mode 100644 src/oauth-client-temp/client/session-getter.ts create mode 100644 src/oauth-client-temp/client/util.ts create mode 100644 src/oauth-client-temp/client/validate-client-metadata.ts create mode 100644 src/oauth-client-temp/disposable-polyfill/index.ts create mode 100644 src/oauth-client-temp/fetch-dpop/index.ts create mode 100644 src/oauth-client-temp/identity-resolver/identity-resolver.ts create mode 100644 src/oauth-client-temp/identity-resolver/index.ts create mode 100644 src/oauth-client-temp/identity-resolver/universal-identity-resolver.ts create mode 100644 src/oauth-client-temp/indexed-db/db-index.web.ts create mode 100644 src/oauth-client-temp/indexed-db/db-object-store.web.ts create mode 100644 src/oauth-client-temp/indexed-db/db-transaction.web.ts create mode 100644 src/oauth-client-temp/indexed-db/db.ts create mode 100644 src/oauth-client-temp/indexed-db/db.web.ts create mode 100644 src/oauth-client-temp/indexed-db/index.web.ts create mode 100644 src/oauth-client-temp/indexed-db/schema.ts create mode 100644 src/oauth-client-temp/indexed-db/util.web.ts create mode 100644 src/oauth-client-temp/jwk-jose/index.ts create mode 100644 src/oauth-client-temp/jwk-jose/jose-key.ts create mode 100644 src/oauth-client-temp/jwk-jose/jose-keyset.ts create mode 100644 src/oauth-client-temp/jwk-jose/util.ts diff --git a/package.json b/package.json index 0269c754cc..235cadaa48 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.6.4", "@miblanchard/react-native-slider": "^2.3.1", - "@pagopa/io-react-native-jwt": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.0.6", "@react-native-async-storage/async-storage": "1.23.1", "@react-native-masked-view/masked-view": "0.3.0", @@ -133,6 +132,7 @@ "expo-web-browser": "~12.8.2", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", + "jose": "^5.2.4", "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", "lande": "^1.0.10", diff --git a/src/oauth-client-temp/client/crypto-implementation.ts b/src/oauth-client-temp/client/crypto-implementation.ts index bf3b340011..237485068d 100644 --- a/src/oauth-client-temp/client/crypto-implementation.ts +++ b/src/oauth-client-temp/client/crypto-implementation.ts @@ -1,11 +1,47 @@ -import {Key} from '../jwk' +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 type {Key} +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) + } +} -export interface CryptoImplementation { - createKey(algs: ) +// 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 new file mode 100644 index 0000000000..d893319c61 --- /dev/null +++ b/src/oauth-client-temp/client/crypto-wrapper.ts @@ -0,0 +1,173 @@ +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 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/oauth-client-temp/client/db.web.ts b/src/oauth-client-temp/client/db.web.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/oauth-client-temp/client/key.ts b/src/oauth-client-temp/client/key.ts new file mode 100644 index 0000000000..228e0471d1 --- /dev/null +++ b/src/oauth-client-temp/client/key.ts @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/oauth-client-temp/client/oauth-callback-error.ts b/src/oauth-client-temp/client/oauth-callback-error.ts new file mode 100644 index 0000000000..9c9c26d19d --- /dev/null +++ b/src/oauth-client-temp/client/oauth-callback-error.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000000..8ca685a94a --- /dev/null +++ b/src/oauth-client-temp/client/oauth-client-factory.ts @@ -0,0 +1,290 @@ +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 new file mode 100644 index 0000000000..aee2d9d81b --- /dev/null +++ b/src/oauth-client-temp/client/oauth-client.ts @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000000..026a04af75 --- /dev/null +++ b/src/oauth-client-temp/client/oauth-database.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..4ca5798435 --- /dev/null +++ b/src/oauth-client-temp/client/oauth-resolver.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000000..265dda9c39 --- /dev/null +++ b/src/oauth-client-temp/client/oauth-server-factory.ts @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000000..fdae49a451 --- /dev/null +++ b/src/oauth-client-temp/client/oauth-server.ts @@ -0,0 +1,287 @@ +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 new file mode 100644 index 0000000000..922abf9067 --- /dev/null +++ b/src/oauth-client-temp/client/oauth-types.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000000..c2889c9673 --- /dev/null +++ b/src/oauth-client-temp/client/session-getter.ts @@ -0,0 +1,139 @@ +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 new file mode 100644 index 0000000000..5e238ac3b7 --- /dev/null +++ b/src/oauth-client-temp/client/util.ts @@ -0,0 +1,160 @@ +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 new file mode 100644 index 0000000000..fdc3aece4d --- /dev/null +++ b/src/oauth-client-temp/client/validate-client-metadata.ts @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000000..ddb9073b16 --- /dev/null +++ b/src/oauth-client-temp/disposable-polyfill/index.ts @@ -0,0 +1,10 @@ +// 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 new file mode 100644 index 0000000000..eb3f3e4a45 --- /dev/null +++ b/src/oauth-client-temp/fetch-dpop/index.ts @@ -0,0 +1,174 @@ +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 new file mode 100644 index 0000000000..88e592ff2b --- /dev/null +++ b/src/oauth-client-temp/identity-resolver/identity-resolver.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000000..bccd3ae900 --- /dev/null +++ b/src/oauth-client-temp/identity-resolver/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000..a9201d93c2 --- /dev/null +++ b/src/oauth-client-temp/identity-resolver/universal-identity-resolver.ts @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000000..b06fdd5844 --- /dev/null +++ b/src/oauth-client-temp/indexed-db/db-index.web.ts @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000000..44c20a7004 --- /dev/null +++ b/src/oauth-client-temp/indexed-db/db-object-store.web.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000000..08fddc9afe --- /dev/null +++ b/src/oauth-client-temp/indexed-db/db-transaction.web.ts @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000000..5d83361657 --- /dev/null +++ b/src/oauth-client-temp/indexed-db/db.ts @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000000..a983b7f530 --- /dev/null +++ b/src/oauth-client-temp/indexed-db/db.web.ts @@ -0,0 +1,113 @@ +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 new file mode 100644 index 0000000000..d1b6d4e0dd --- /dev/null +++ b/src/oauth-client-temp/indexed-db/index.web.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000000..f8736b2a19 --- /dev/null +++ b/src/oauth-client-temp/indexed-db/schema.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000..6e52b5919c --- /dev/null +++ b/src/oauth-client-temp/indexed-db/util.web.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000000..179625dd51 --- /dev/null +++ b/src/oauth-client-temp/jwk-jose/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000000..baeb31ff2c --- /dev/null +++ b/src/oauth-client-temp/jwk-jose/jose-key.ts @@ -0,0 +1,115 @@ +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 new file mode 100644 index 0000000000..27baefcfda --- /dev/null +++ b/src/oauth-client-temp/jwk-jose/jose-keyset.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000000..f75cdb6671 --- /dev/null +++ b/src/oauth-client-temp/jwk-jose/util.ts @@ -0,0 +1,9 @@ +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 index 226a1bb662..8be3555018 100644 --- a/src/oauth-client-temp/jwk/alg.ts +++ b/src/oauth-client-temp/jwk/alg.ts @@ -1,6 +1,6 @@ -import { Jwk } from './jwk.js' +import {Jwk} from './jwk' -declare const process: undefined | { versions?: { node?: string } } +declare const process: undefined | {versions?: {node?: string}} const IS_NODE_RUNTIME = typeof process !== 'undefined' && typeof process?.versions?.node === 'string' diff --git a/src/oauth-client-temp/jwk/index.ts b/src/oauth-client-temp/jwk/index.ts index 3e1c678233..79393d6eea 100644 --- a/src/oauth-client-temp/jwk/index.ts +++ b/src/oauth-client-temp/jwk/index.ts @@ -1,9 +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' +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/jwks.ts b/src/oauth-client-temp/jwk/jwks.ts index 1ec8d382ba..b1b333d986 100644 --- a/src/oauth-client-temp/jwk/jwks.ts +++ b/src/oauth-client-temp/jwk/jwks.ts @@ -1,6 +1,6 @@ -import { z } from 'zod' +import {z} from 'zod' -import { jwkPubSchema, jwkSchema } from './jwk.js' +import {jwkPubSchema, jwkSchema} from './jwk' export const jwksSchema = z .object({ diff --git a/src/oauth-client-temp/jwk/jwt-decode.ts b/src/oauth-client-temp/jwk/jwt-decode.ts index 287f8fbc08..7fc5f3ef3f 100644 --- a/src/oauth-client-temp/jwk/jwt-decode.ts +++ b/src/oauth-client-temp/jwk/jwt-decode.ts @@ -1,18 +1,12 @@ -import { b64uDecode } from '@atproto/b64' - -import { ui8ToString } from './util.js' -import { - JwtHeader, - JwtPayload, - jwtHeaderSchema, - jwtPayloadSchema, -} from './jwt.js' +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('.') + const {0: headerEnc, 1: payloadEnc, length} = jwt.split('.') if (length > 3 || length < 2) { throw new TypeError('invalid JWT input') } @@ -28,5 +22,5 @@ export function unsafeDecodeJwt(jwt: string): { JSON.parse(ui8ToString(b64uDecode(payloadEnc!))), ) - return { header, payload } + return {header, payload} } diff --git a/src/oauth-client-temp/jwk/jwt-verify.ts b/src/oauth-client-temp/jwk/jwt-verify.ts index 5eeca81e53..3e05f60ae5 100644 --- a/src/oauth-client-temp/jwk/jwt-verify.ts +++ b/src/oauth-client-temp/jwk/jwt-verify.ts @@ -1,5 +1,5 @@ -import { JwtHeader, JwtPayload } from './jwt.js' -import { RequiredKey } from './util.js' +import {JwtHeader, JwtPayload} from './jwt' +import {RequiredKey} from './util' export type VerifyOptions = { audience?: string | readonly string[] diff --git a/src/oauth-client-temp/jwk/jwt.ts b/src/oauth-client-temp/jwk/jwt.ts index c07af8cb73..51de57916c 100644 --- a/src/oauth-client-temp/jwk/jwt.ts +++ b/src/oauth-client-temp/jwk/jwt.ts @@ -1,6 +1,6 @@ -import { z } from 'zod' +import {z} from 'zod' -import { jwkPubSchema } from './jwk.js' +import {jwkPubSchema} from './jwk' export const JWT_REGEXP = /^[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){1,2}$/ export const jwtSchema = z diff --git a/src/oauth-client-temp/jwk/key.ts b/src/oauth-client-temp/jwk/key.ts index d8af69e0df..c9923f043e 100644 --- a/src/oauth-client-temp/jwk/key.ts +++ b/src/oauth-client-temp/jwk/key.ts @@ -1,8 +1,8 @@ -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' +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) { @@ -11,13 +11,13 @@ export abstract class Key { } get isPrivate(): boolean { - const { jwk } = this + const {jwk} = this if ('d' in jwk && jwk.d !== undefined) return true return this.isSymetric } get isSymetric(): boolean { - const { jwk } = this + const {jwk} = this if ('k' in jwk && jwk.k !== undefined) return true return false } @@ -30,7 +30,7 @@ export abstract class Key { get publicJwk(): Jwk | undefined { if (this.isSymetric) return undefined if (this.isPrivate) { - const { d: _, ...jwk } = this.jwk as any + const {d: _, ...jwk} = this.jwk as any return jwk } return this.jwk @@ -39,8 +39,8 @@ export abstract class Key { @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 }) + const {kty, crv, e, n, x, y} = this.jwk as any + return jwkSchema.parse({crv, e, kty, n, x, y}) } get use() { @@ -60,7 +60,7 @@ export abstract class Key { } get crv() { - return (this.jwk as undefined | Extract)?.crv + return (this.jwk as undefined | Extract)?.crv } get canVerify() { diff --git a/src/oauth-client-temp/jwk/keyset.ts b/src/oauth-client-temp/jwk/keyset.ts index 9be83677d3..09137aaba5 100644 --- a/src/oauth-client-temp/jwk/keyset.ts +++ b/src/oauth-client-temp/jwk/keyset.ts @@ -1,16 +1,16 @@ -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 {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 { - Override, cachedGetter, isDefined, matchesAny, + Override, preferredOrderCmp, -} from './util.js' +} from './util' export type JwtSignHeader = Override> @@ -50,7 +50,7 @@ export class Keyset implements Iterable { if (!keys.length) throw new Error('Keyset is empty') const kids = new Set() - for (const { kid } of keys) { + for (const {kid} of keys) { if (!kid) continue if (kids.has(kid)) throw new Error(`Duplicate key id: ${kid}`) @@ -87,7 +87,7 @@ export class Keyset implements Iterable { } has(kid: string): boolean { - return this.keys.some((key) => key.kid === kid) + return this.keys.some(key => key.kid === kid) } get(search: KeySearch): K { @@ -115,7 +115,7 @@ export class Keyset implements Iterable { } if (Array.isArray(search.alg)) { - if (!search.alg.some((a) => key.algorithms.includes(a))) continue + if (!search.alg.some(a => key.algorithms.includes(a))) continue } else if (typeof search.alg === 'string') { if (!key.algorithms.includes(search.alg)) continue } @@ -125,10 +125,10 @@ export class Keyset implements Iterable { } findSigningKey(search: Omit): [key: Key, alg: string] { - const { kid, alg } = search + const {kid, alg} = search const matchingKeys: Key[] = [] - for (const key of this.list({ kid, alg, use: 'sig' })) { + for (const key of this.list({kid, alg, use: 'sig'})) { // Not a signing key if (!key.canSign) continue @@ -140,7 +140,7 @@ export class Keyset implements Iterable { const isAllowedAlg = matchesAny(alg) const candidates = matchingKeys.map( - (key) => [key, key.algorithms.filter(isAllowedAlg)] as const, + key => [key, key.algorithms.filter(isAllowedAlg)] as const, ) // Return the first candidates that matches the preferred algorithms @@ -165,11 +165,11 @@ export class Keyset implements Iterable { } async sign( - { alg: searchAlg, kid: searchKid, ...header }: JwtSignHeader, + {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 } + 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) @@ -182,12 +182,12 @@ export class Keyset implements Iterable { P extends Record = JwtPayload, C extends string = string, >(token: Jwt, options?: VerifyOptions) { - const { header } = unsafeDecodeJwt(token) - const { kid, alg } = header + const {header} = unsafeDecodeJwt(token) + const {kid, alg} = header const errors: unknown[] = [] - for (const key of this.list({ use: 'sig', kid, alg })) { + for (const key of this.list({use: 'sig', kid, alg})) { try { return await key.verifyJwt(token, options) } catch (err) { diff --git a/src/screens/Login/hooks/useLogin.ts b/src/screens/Login/hooks/useLogin.ts index b5c9dbc4b1..f1626d7d9e 100644 --- a/src/screens/Login/hooks/useLogin.ts +++ b/src/screens/Login/hooks/useLogin.ts @@ -1,6 +1,5 @@ import React from 'react' import * as Browser from 'expo-web-browser' -import {OAuthClientFactory} from '@atproto/oauth-client' import { buildOAuthUrl, @@ -12,9 +11,11 @@ import { OAUTH_RESPONSE_TYPES, OAUTH_SCOPE, } from 'lib/oauth' +import {CryptoImplementation} from '#/oauth-client-temp/client/crypto-implementation' +import {OAuthClientFactory} from '#/oauth-client-temp/client/oauth-client-factory' // TODO remove hack -const serviceUrl = 'http://localhost:2583/oauth/authorize' +const serviceUrl = 'http://localhost' // Service URL here is just a placeholder, this isn't how it will actually work export function useLogin(serviceUrl: string | undefined) { @@ -29,6 +30,7 @@ export function useLogin(serviceUrl: string | undefined) { dpop_bound_access_tokens: DPOP_BOUND_ACCESS_TOKENS, application_type: OAUTH_APPLICATION_TYPE, }, + cryptoImplementation: new CryptoImplementation(crypto), }) if (!serviceUrl) return diff --git a/yarn.lock b/yarn.lock index a3e634f6aa..d05d3aa797 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4478,14 +4478,6 @@ mkdirp "^1.0.4" rimraf "^3.0.2" -"@pagopa/io-react-native-jwt@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@pagopa/io-react-native-jwt/-/io-react-native-jwt-1.1.0.tgz#9a8e99672a683a32a27785eb01a7f108561ca8dd" - integrity sha512-R/Cgiu3Qb/7LnzQstUTGkNnsKfQ5lc/O3eSAkzZPGQqQb4hoSch4N/2JgqXRZPeTNfFA2vDmYbV6fSidMDL2xA== - dependencies: - abab "^2.0.6" - zod "^3.21.4" - "@pmmmwh/react-refresh-webpack-plugin@^0.5.11", "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": version "0.5.11" resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz#7c2268cedaa0644d677e8c4f377bc8fb304f714a" @@ -15244,6 +15236,11 @@ jose@^5.0.1: resolved "https://registry.yarnpkg.com/jose/-/jose-5.1.3.tgz#303959d85c51b5cb14725f930270b72be56abdca" integrity sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw== +jose@^5.2.4: + version "5.2.4" + resolved "https://registry.yarnpkg.com/jose/-/jose-5.2.4.tgz#c0d296caeeed0b8444a8b8c3b68403d61aa4ed72" + integrity sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg== + js-base64@^3.7.2: version "3.7.5" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca"