Skip to content

Commit

Permalink
fixup! feat(oauth): add oauth provider & client libs
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieusieben committed May 2, 2024
1 parent 3eaff2c commit cd533fa
Show file tree
Hide file tree
Showing 3 changed files with 61 additions and 85 deletions.
58 changes: 29 additions & 29 deletions packages/fetch-dpop/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { Fetch, cancelBody, peekJson } from '@atproto/fetch'
import { Key } from '@atproto/jwk'
import { SimpleStore } from '@atproto/simple-store'

const crypto = globalThis.crypto?.subtle
? (globalThis.crypto as Crypto)
: undefined
// "undefined" in non https environments or environments without crypto
const subtle = globalThis.crypto?.subtle as SubtleCrypto | undefined

const ReadableStream = globalThis.ReadableStream as
| typeof globalThis.ReadableStream
Expand Down Expand Up @@ -34,7 +33,7 @@ export function dpopFetchWrapper({
iss,
supportedAlgs,
nonces,
sha256 = typeof crypto !== 'undefined' ? subtleSha256 : undefined,
sha256 = typeof subtle !== 'undefined' ? subtleSha256 : undefined,
isAuthServer,
fetch = globalThis.fetch,
}: DpopFetchWrapperOptions) {
Expand Down Expand Up @@ -83,9 +82,7 @@ export async function dpopFetch(
}

const request: Request =
init === undefined && input instanceof Request
? input
: new Request(input, init)
init == null && input instanceof Request ? input : new Request(input, init)

const authorizationHeader = request.headers.get('Authorization')
const ath = authorizationHeader?.startsWith('DPoP ')
Expand All @@ -99,34 +96,34 @@ export async function dpopFetch(
try {
initNonce = await nonces.get(origin)
} catch {
// Ignore cache.get errors
// Ignore get errors, we will just not send a nonce
}

const initProof = await buildProof(key, alg, iss, method, url, initNonce, ath)
request.headers.set('DPoP', initProof)

const response = await fetch(request)
const initResponse = await fetch.call(this, request)

// Make sure the response body is consumed. Either by the caller (when the
// response is returned), of if an error is thrown (catch block).

const nextNonce = response.headers.get('DPoP-Nonce')
const nextNonce = initResponse.headers.get('DPoP-Nonce')
if (!nextNonce || nextNonce === initNonce) {
// No nonce was returned or it is the same as the one we sent. No need to
// update the cache, or retry the request.
return response
// update the nonce store, or retry the request.
return initResponse
}

// Store the fresh nonce for future requests
try {
await nonces.set(origin, nextNonce)
} catch {
// Ignore cache.set errors
// Ignore set errors
}

if (!(await isUseDpopNonceError(response, isAuthServer))) {
if (!(await isUseDpopNonceError(initResponse, isAuthServer))) {
// Not a "use_dpop_nonce" error, so there is no need to retry
return response
return initResponse
}

// If the input stream was already consumed, we cannot retry the request. A
Expand All @@ -136,31 +133,34 @@ export async function dpopFetch(

if (input === request) {
// The input request body was consumed. We cannot retry the request.
return response
return initResponse
}

if (ReadableStream && init?.body instanceof ReadableStream) {
// The init body was consumed. We cannot retry the request.
return response
return initResponse
}

// We will now retry the request with the fresh nonce. The initial response
// body must be consumed (see cancelBody's doc).
// We will now retry the request with the fresh nonce.

// Awaiting later to improve performance
const promise = cancelBody(response, null)
// The initial response body must be consumed (see cancelBody's doc).
await cancelBody(initResponse, (err) => {
// TODO: What's the best way to handle this (i.e. what can cause this to
// happen)?
// 0) Not provide this callback and wait for the cancellation synchronously?
// 1) Crash the server? (`throw err`)
// 2) Log the error and continue?
// 3) Silently ignore the error?
// 4) Let the caller decide by providing a callback?

// Avoid "unhandledRejection" if rejected before the await
promise.catch(() => {})
console.error('Failed to cancel response body', err)
})

const nextProof = await buildProof(key, alg, iss, method, url, nextNonce, ath)

const nextRequest = new Request(input, init)
nextRequest.headers.set('DPoP', nextProof)

await promise

return fetch(nextRequest)
return fetch.call(this, nextRequest)
}

async function buildProof(
Expand Down Expand Up @@ -241,13 +241,13 @@ function negotiateAlg(key: Key, supportedAlgs: string[] | undefined): string {
}

function subtleSha256(input: string): Promise<string> {
if (crypto == null) {
if (subtle == null) {
throw new Error(
`crypto.subtle is not available in this environment. Please provide a sha256 function.`,
)
}

return crypto.subtle
return subtle
.digest('SHA-256', new TextEncoder().encode(input))
.then((digest) => b64uEncode(new Uint8Array(digest)))
}
80 changes: 30 additions & 50 deletions packages/fetch/src/fetch-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,7 @@ import { z } from 'zod'

import { FetchError, FetchErrorOptions } from './fetch-error.js'
import { TransformedResponse } from './transformed-response.js'
import {
Json,
MaxBytesTransformStream,
ifObject,
ifString,
nextEventLoop,
thrower,
} from './util.js'
import { Json, MaxBytesTransformStream, ifObject, ifString } from './util.js'

export type ResponseTranformer = Transformer<Response>
export type ResponseMessageGetter = Transformer<Response, string | undefined>
Expand Down Expand Up @@ -126,48 +119,49 @@ export class FetchResponseError extends FetchError {
}
}

export function logCancellationError(err: unknown): void {
console.warn('Failed to cancel response body', err)
}

/**
* If the transformer results in an error, ensure that the response body is
* consumed as, in some environments (Node 👀), the response will not
* automatically be GC'd.
*
* @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
* @param [onPurgeError] - Callback to handle any async body cancelling error.
* Defaults to logging the error. Use `null` if the request was not cloned.
* @param [onCancellationError] - Callback to handle any async body cancelling
* error. Defaults to logging the error. Do not use `null` if the request is
* cloned.
*/
export function cancelBodyOnError<T>(
transformer: Transformer<Response, T>,
onPurgeError: null | ((err: unknown) => void) = (err) => {
console.error('Failed to cancel response body', err)
},
onCancellationError: null | ((err: unknown) => void) = logCancellationError,
): (response: Response) => Promise<T> {
return async (response) => {
try {
return await transformer(response)
} catch (err) {
await cancelBody(response, onPurgeError)
await cancelBody(response, onCancellationError ?? undefined)
throw err
}
}
}

/**
* @param [onAsyncError] - Callback to handle any errors that occurs after the
* promise was resolved. Defaults to generating an "unhandledRejection".
* @param [onCancellationError] - Callback that will trigger to asynchronously
* handle any error that occurs while cancelling the response body. Providing
* this will speed up the process and avoid potential deadlocks. Defaults to
* awaiting the cancellation operation.
* @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
* @note awaiting this function's result might result in a dead lock. Indeed, if
* the response was cloned(), the response.body.cancel() method will not resolve
* until the other response's body is consumed/cancelled. For this reason, this
* function will, by default, always resolve after 2 event loops iterations. If
* an error occurs after that delay, it will be emitted through the
* `onAsyncError` callback. By default, this callback will generate an
* "unhandledRejection". If the `onAsyncError` callback is `null`, the promise
* will resolve synchronously with the body cancellation (causing potential dead
* locks if the response was cloned).
* @note awaiting this function's result, when no `onCancellationError` is
* provided, might result in a dead lock. Indeed, if the response was cloned(),
* the response.body.cancel() method will not resolve until the other response's
* body is consumed/cancelled.
*
* @example
* ```ts
* // Will generate an "unhandledRejection" if an error occurs while cancelling
* // the response body. This will likely crash the process.
* // Make sure response was not cloned, or that every cloned response was
* // consumed/cancelled before awaiting this function's result.
* await cancelBody(response)
* ```
* @example
Expand All @@ -179,14 +173,14 @@ export function cancelBodyOnError<T>(
* ```
* @example
* ```ts
* // Make sure response was not cloned, or that every cloned response was
* // consumed/cancelled before awaiting this function's result.
* await cancelBody(response, null)
* // Will generate an "unhandledRejection" if an error occurs while cancelling
* // the response body. This will likely crash the process.
* await cancelBody(response, (err) => { throw err })
* ```
*/
export async function cancelBody(
input: Response | Request,
onAsyncError: null | ((err: unknown) => void) = thrower,
onCancellationError?: (err: unknown) => void,
): Promise<void> {
if (
input.body &&
Expand All @@ -195,25 +189,11 @@ export async function cancelBody(
// Support for alternative fetch implementations
typeof input.body.cancel === 'function'
) {
return new Promise((resolve, reject) => {
let forcefullyResolved = false
let cancel = nextEventLoop(() => {
cancel = nextEventLoop(() => {
if (onAsyncError != null) {
forcefullyResolved = true
resolve()
}
})
})

input
.body!.cancel()
.finally(() => cancel?.())
.then(resolve, (err: unknown) => {
if (forcefullyResolved) onAsyncError!(err)
else reject(err)
})
})
if (typeof onCancellationError === 'function') {
void input.body.cancel().catch(onCancellationError)
} else {
await input.body.cancel()
}
}
}

Expand Down
8 changes: 2 additions & 6 deletions packages/fetch/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,12 @@ export const ifString = <V>(v?: V) => (typeof v === 'string' ? v : undefined)
export const ifNumber = <V>(v?: V) => (typeof v === 'number' ? v : undefined)
export const ifNull = <V>(v?: V) => (v === null ? v : undefined)

export const noop = () => {}

export function thrower(err: unknown) {
throw err
}

export function nextEventLoop(cb: () => void) {
const timer = setTimeout(cb)
timer.unref?.()
return () => clearTimeout(timer)
}

export class MaxBytesTransformStream extends TransformStream<
Uint8Array,
Uint8Array
Expand Down

0 comments on commit cd533fa

Please sign in to comment.