@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
306 lines • 12.7 kB
JavaScript
;
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
if (async) {
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
if (async) inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
env.stack.push({ value: value, dispose: dispose, async: async });
}
else if (async) {
env.stack.push({ async: true });
}
return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
return function (env) {
function fail(e) {
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while (r = env.stack.pop()) {
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
}
else s |= 1;
}
catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
};
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
Object.defineProperty(exports, "__esModule", { value: true });
exports.OAuthServerAgent = void 0;
const fetch_1 = require("@atproto-labs/fetch");
const oauth_types_1 = require("@atproto/oauth-types");
const atproto_token_response_js_1 = require("./atproto-token-response.js");
const constants_js_1 = require("./constants.js");
const token_refresh_error_js_1 = require("./errors/token-refresh-error.js");
const fetch_dpop_js_1 = require("./fetch-dpop.js");
const oauth_response_error_js_1 = require("./oauth-response-error.js");
const util_js_1 = require("./util.js");
class OAuthServerAgent {
constructor(dpopKey, serverMetadata, clientMetadata, dpopNonces, oauthResolver, runtime, keyset, fetch) {
Object.defineProperty(this, "dpopKey", {
enumerable: true,
configurable: true,
writable: true,
value: dpopKey
});
Object.defineProperty(this, "serverMetadata", {
enumerable: true,
configurable: true,
writable: true,
value: serverMetadata
});
Object.defineProperty(this, "clientMetadata", {
enumerable: true,
configurable: true,
writable: true,
value: clientMetadata
});
Object.defineProperty(this, "dpopNonces", {
enumerable: true,
configurable: true,
writable: true,
value: dpopNonces
});
Object.defineProperty(this, "oauthResolver", {
enumerable: true,
configurable: true,
writable: true,
value: oauthResolver
});
Object.defineProperty(this, "runtime", {
enumerable: true,
configurable: true,
writable: true,
value: runtime
});
Object.defineProperty(this, "keyset", {
enumerable: true,
configurable: true,
writable: true,
value: keyset
});
Object.defineProperty(this, "dpopFetch", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.dpopFetch = (0, fetch_dpop_js_1.dpopFetchWrapper)({
fetch: (0, fetch_1.bindFetch)(fetch),
iss: clientMetadata.client_id,
key: dpopKey,
supportedAlgs: serverMetadata.dpop_signing_alg_values_supported,
sha256: async (v) => runtime.sha256(v),
nonces: dpopNonces,
isAuthServer: true,
});
}
get issuer() {
return this.serverMetadata.issuer;
}
async revoke(token) {
try {
await this.request('revocation', { token });
}
catch {
// Don't care
}
}
async exchangeCode(code, codeVerifier) {
const now = Date.now();
const tokenResponse = await this.request('token', {
grant_type: 'authorization_code',
redirect_uri: this.clientMetadata.redirect_uris[0],
code,
code_verifier: codeVerifier,
});
try {
// /!\ IMPORTANT /!\
//
// The tokenResponse MUST always be valid before the "sub" it contains
// can be trusted (see Atproto's OAuth spec for details).
const aud = await this.verifyIssuer(tokenResponse.sub);
return {
aud,
sub: tokenResponse.sub,
iss: this.issuer,
scope: tokenResponse.scope,
refresh_token: tokenResponse.refresh_token,
access_token: tokenResponse.access_token,
token_type: tokenResponse.token_type,
expires_at: typeof tokenResponse.expires_in === 'number'
? new Date(now + tokenResponse.expires_in * 1000).toISOString()
: undefined,
};
}
catch (err) {
await this.revoke(tokenResponse.access_token);
throw err;
}
}
async refresh(tokenSet) {
if (!tokenSet.refresh_token) {
throw new token_refresh_error_js_1.TokenRefreshError(tokenSet.sub, 'No refresh token available');
}
// /!\ IMPORTANT /!\
//
// The "sub" MUST be a DID, whose issuer authority is indeed the server we
// are trying to obtain credentials from. Note that we are doing this
// *before* we actually try to refresh the token:
// 1) To avoid unnecessary refresh
// 2) So that the refresh is the last async operation, ensuring as few
// async operations happen before the result gets a chance to be stored.
const aud = await this.verifyIssuer(tokenSet.sub);
const now = Date.now();
const tokenResponse = await this.request('token', {
grant_type: 'refresh_token',
refresh_token: tokenSet.refresh_token,
});
return {
aud,
sub: tokenSet.sub,
iss: this.issuer,
scope: tokenResponse.scope,
refresh_token: tokenResponse.refresh_token,
access_token: tokenResponse.access_token,
token_type: tokenResponse.token_type,
expires_at: typeof tokenResponse.expires_in === 'number'
? new Date(now + tokenResponse.expires_in * 1000).toISOString()
: undefined,
};
}
/**
* VERY IMPORTANT ! Always call this to process token responses.
*
* 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.
*
* @returns The user's PDS URL (the resource server for the user)
*/
async verifyIssuer(sub) {
const env_1 = { stack: [], error: void 0, hasError: false };
try {
const signal = __addDisposableResource(env_1, (0, util_js_1.timeoutSignal)(10e3), false);
const resolved = await this.oauthResolver.resolveFromIdentity(sub, {
noCache: true,
allowStale: false,
signal,
});
if (this.issuer !== resolved.metadata.issuer) {
// Best case scenario; the user switched PDS. Worst case scenario; a bad
// actor is trying to impersonate a user. In any case, we must not allow
// this token to be used.
throw new TypeError('Issuer mismatch');
}
return resolved.identity.pds.href;
}
catch (e_1) {
env_1.error = e_1;
env_1.hasError = true;
}
finally {
__disposeResources(env_1);
}
}
async request(endpoint, payload) {
const url = this.serverMetadata[`${endpoint}_endpoint`];
if (!url)
throw new Error(`No ${endpoint} endpoint available`);
const auth = await this.buildClientAuth(endpoint);
const { response, json } = await this.dpopFetch(url, {
method: 'POST',
headers: { ...auth.headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, ...auth.payload }),
}).then((0, fetch_1.fetchJsonProcessor)());
if (response.ok) {
switch (endpoint) {
case 'token':
return atproto_token_response_js_1.atprotoTokenResponseSchema.parse(json);
case 'pushed_authorization_request':
return oauth_types_1.oauthParResponseSchema.parse(json);
default:
return json;
}
}
else {
throw new oauth_response_error_js_1.OAuthResponseError(response, json);
}
}
async buildClientAuth(endpoint) {
const methodSupported = this.serverMetadata[`token_endpoint_auth_methods_supported`];
const 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[`token_endpoint_auth_signing_alg_values_supported`] ?? constants_js_1.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) => typeof v === 'string');
return {
payload: {
client_id: this.clientMetadata.client_id,
client_assertion_type: oauth_types_1.CLIENT_ASSERTION_TYPE_JWT_BEARER,
client_assertion: await this.keyset.createJwt({ alg, kid }, {
iss: this.clientMetadata.client_id,
sub: this.clientMetadata.client_id,
aud: this.serverMetadata.issuer,
jti: await this.runtime.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`);
}
}
exports.OAuthServerAgent = OAuthServerAgent;
//# sourceMappingURL=oauth-server-agent.js.map