@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
176 lines • 7.11 kB
JavaScript
import { base64url } from 'multiformats/bases/base64';
import { cancelBody, peekJson, } from '@atproto-labs/fetch';
// "undefined" in non https environments or environments without crypto
const subtle = globalThis.crypto?.subtle;
const ReadableStream = globalThis.ReadableStream;
export function dpopFetchWrapper({ key,
// @TODO we should provide a default based on specs
supportedAlgs, nonces, sha256 = typeof subtle !== 'undefined' ? subtleSha256 : undefined, isAuthServer, fetch = globalThis.fetch, }) {
if (!sha256) {
throw new TypeError(`crypto.subtle is not available in this environment. Please provide a sha256 function.`);
}
// Throws if negotiation fails
const alg = negotiateAlg(key, supportedAlgs);
return async function (input, init) {
const request = init == null && input instanceof Request
? input
: new Request(input, init);
const authorizationHeader = request.headers.get('Authorization');
const ath = authorizationHeader?.startsWith('DPoP ')
? await sha256(authorizationHeader.slice(5))
: undefined;
const { origin } = new URL(request.url);
const htm = request.method;
const htu = buildHtu(request.url);
let initNonce;
try {
initNonce = await nonces.get(origin);
}
catch {
// Ignore get errors, we will just not send a nonce
}
const initProof = await buildProof(key, alg, htm, htu, initNonce, ath);
request.headers.set('DPoP', initProof);
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 = 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 nonce store, or retry the request.
return initResponse;
}
// Store the fresh nonce for future requests
try {
await nonces.set(origin, nextNonce);
}
catch {
// Ignore set errors
}
const shouldRetry = await isUseDpopNonceError(initResponse, isAuthServer);
if (!shouldRetry) {
// Not a "use_dpop_nonce" error, so there is no need to retry
return initResponse;
}
// If the input stream was already consumed, we cannot retry the request. A
// solution would be to clone() the request but that would bufferize the
// entire stream in memory which can lead to memory starvation. Instead, we
// will return the original response and let the calling code handle retries.
if (input === request) {
// The input request body was consumed. We cannot retry the request.
return initResponse;
}
if (ReadableStream && init?.body instanceof ReadableStream) {
// The init body was consumed. We cannot retry the request.
return initResponse;
}
// We will now retry the request with the fresh nonce.
// The initial response body must be consumed (see cancelBody's doc).
await cancelBody(initResponse, 'log');
const nextProof = await buildProof(key, alg, htm, htu, nextNonce, ath);
const nextRequest = new Request(input, init);
nextRequest.headers.set('DPoP', nextProof);
const retryRequest = await fetch.call(this, nextRequest);
const retryNonce = retryRequest.headers.get('DPoP-Nonce');
if (!retryNonce || retryNonce === initNonce) {
// No nonce was returned or it is the same as the one we sent. No need to
// update the nonce store, or retry the request.
return retryRequest;
}
// Store the fresh nonce for future requests
try {
await nonces.set(origin, retryNonce);
}
catch {
// Ignore set errors
}
return retryRequest;
};
}
/**
* Strip query and fragment
*
* @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6}
*/
function buildHtu(url) {
const fragmentIndex = url.indexOf('#');
const queryIndex = url.indexOf('?');
const end = fragmentIndex === -1
? queryIndex
: queryIndex === -1
? fragmentIndex
: Math.min(fragmentIndex, queryIndex);
return end === -1 ? url : url.slice(0, end);
}
async function buildProof(key, alg, htm, htu, nonce, ath) {
const jwk = key.bareJwk;
if (!jwk) {
throw new Error('Only asymmetric keys can be used as DPoP proofs');
}
const now = Math.floor(Date.now() / 1e3);
return key.createJwt(
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
{
alg,
typ: 'dpop+jwt',
jwk,
}, {
iat: now,
// 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, isAuthServer) {
// https://datatracker.ietf.org/doc/html/rfc6750#section-3
// https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
if (isAuthServer === undefined || isAuthServer === false) {
if (response.status === 401) {
const wwwAuth = response.headers.get('WWW-Authenticate');
if (wwwAuth?.startsWith('DPoP')) {
return wwwAuth.includes('error="use_dpop_nonce"');
}
}
}
// https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
if (isAuthServer === undefined || isAuthServer === true) {
if (response.status === 400) {
try {
const json = await peekJson(response, 10 * 1024);
return json?.['error'] === 'use_dpop_nonce';
}
catch {
// Response too big (to be "use_dpop_nonce" error) or invalid JSON
return false;
}
}
}
return false;
}
function negotiateAlg(key, supportedAlgs) {
if (supportedAlgs) {
// Use order of supportedAlgs as preference
const alg = supportedAlgs.find((a) => key.algorithms.includes(a));
if (alg)
return alg;
}
else {
const [alg] = key.algorithms;
if (alg)
return alg;
}
throw new Error('Key does not match any alg supported by the server');
}
async function subtleSha256(input) {
if (subtle == null) {
throw new Error(`crypto.subtle is not available in this environment. Please provide a sha256 function.`);
}
const bytes = new TextEncoder().encode(input);
const digest = await subtle.digest('SHA-256', bytes);
const digestBytes = new Uint8Array(digest);
return base64url.baseEncode(digestBytes);
}
//# sourceMappingURL=fetch-dpop.js.map