UNPKG

@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
"use strict"; 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