UNPKG

@atproto/oauth-client

Version:

OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).

261 lines 13.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.SessionGetter = void 0; const simple_store_1 = require("@atproto-labs/simple-store"); const token_invalid_error_js_1 = require("./errors/token-invalid-error.js"); const token_refresh_error_js_1 = require("./errors/token-refresh-error.js"); const token_revoked_error_js_1 = require("./errors/token-revoked-error.js"); const oauth_response_error_js_1 = require("./oauth-response-error.js"); const util_js_1 = require("./util.js"); /** * 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 sub). */ class SessionGetter extends simple_store_1.CachedGetter { constructor(sessionStore, serverFactory, runtime) { super(async (sub, options, storedSession) => { // There needs to be a previous session to be able to refresh. If // storedSession is undefined, it means that the store does not contain // a session for the given sub. if (storedSession === undefined) { // Because the session is not in the store, this.delStored() method // will not be called by the CachedGetter class (because there is // nothing to delete). This would typically happen if there is no // synchronization mechanism between instances of this class. Let's // make sure an event is dispatched here if this occurs. const msg = 'The session was deleted by another process'; const cause = new token_refresh_error_js_1.TokenRefreshError(sub, msg); this.dispatchEvent('deleted', { sub, cause }); throw cause; } // From this point forward, throwing a TokenRefreshError will result in // this.delStored() being called, resulting in an event being // dispatched, even if the session was removed from the store through a // concurrent access (which, normally, should not happen if a proper // runtime lock was provided). const { dpopKey, tokenSet } = storedSession; if (sub !== tokenSet.sub) { // Fool-proofing (e.g. against invalid session storage) throw new token_refresh_error_js_1.TokenRefreshError(sub, 'Stored session sub mismatch'); } if (!tokenSet.refresh_token) { throw new token_refresh_error_js_1.TokenRefreshError(sub, 'No refresh token available'); } // Since refresh tokens can only be used once, we might run into // concurrency issues if multiple instances (e.g. browser tabs) are // trying to refresh the same token simultaneously. The chances of this // happening when multiple instances are started simultaneously is // reduced by randomizing the expiry time (see isStale() below). The // best solution is to use a mutex/lock to ensure that only one instance // is refreshing the token at a time (runtime.usingLock) but that is not // always possible. If no lock implementation is provided, we will use // the store to check if a concurrent refresh occurred. const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey); // Because refresh tokens can only be used once, we must not use the // "signal" to abort the refresh, or throw any abort error beyond this // point. Any thrown error beyond this point will prevent the // TokenGetter from obtaining, and storing, the new token set, // effectively rendering the currently saved session unusable. options?.signal?.throwIfAborted(); try { const newTokenSet = await server.refresh(tokenSet); if (sub !== newTokenSet.sub) { // The server returned another sub. Was the tokenSet manipulated? throw new token_refresh_error_js_1.TokenRefreshError(sub, 'Token set sub mismatch'); } return { dpopKey, tokenSet: newTokenSet }; } catch (cause) { // If the refresh token is invalid, let's try to recover from // concurrency issues, or make sure the session is deleted by throwing // a TokenRefreshError. if (cause instanceof oauth_response_error_js_1.OAuthResponseError && cause.status === 400 && cause.error === 'invalid_grant') { // In case there is no lock implementation in the runtime, we will // wait for a short time to give the other concurrent instances a // chance to finish their refreshing of the token. If a concurrent // refresh did occur, we will pretend that this one succeeded. if (!runtime.hasImplementationLock) { await new Promise((r) => setTimeout(r, 1000)); const stored = await this.getStored(sub); if (stored === undefined) { // A concurrent refresh occurred and caused the session to be // deleted (for a reason we can't know at this point). // Using a distinct error message mainly for debugging // purposes. Also, throwing a TokenRefreshError to trigger // deletion through the deleteOnError callback. const msg = 'The session was deleted by another process'; throw new token_refresh_error_js_1.TokenRefreshError(sub, msg, { cause }); } else 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; } else { // There were no concurrent refresh. The token is (likely) // simply no longer valid. } } // Make sure the session gets deleted from the store const msg = cause.errorDescription ?? 'The session was revoked'; throw new token_refresh_error_js_1.TokenRefreshError(sub, msg, { cause }); } throw cause; } }, sessionStore, { isStale: (sub, { tokenSet }) => { return (tokenSet.expires_at != null && new Date(tokenSet.expires_at).getTime() < Date.now() + // Add some lee way to ensure the token is not expired when it // reaches the server. 10e3 + // Add some randomness to reduce the chances of multiple // instances trying to refresh the token at the same. 30e3 * Math.random()); }, onStoreError: async (err, sub, { 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.refresh_token ?? tokenSet.access_token); throw err; }, deleteOnError: async (err) => // Optimization: More likely to happen first err instanceof token_refresh_error_js_1.TokenRefreshError || err instanceof token_revoked_error_js_1.TokenRevokedError || err instanceof token_invalid_error_js_1.TokenInvalidError, }); Object.defineProperty(this, "runtime", { enumerable: true, configurable: true, writable: true, value: runtime }); Object.defineProperty(this, "eventTarget", { enumerable: true, configurable: true, writable: true, value: new util_js_1.CustomEventTarget() }); } addEventListener(type, callback, options) { this.eventTarget.addEventListener(type, callback, options); } removeEventListener(type, callback, options) { this.eventTarget.removeEventListener(type, callback, options); } dispatchEvent(type, detail) { return this.eventTarget.dispatchCustomEvent(type, detail); } async setStored(sub, session) { // Prevent tampering with the stored value if (sub !== session.tokenSet.sub) { throw new TypeError('Token set does not match the expected sub'); } await super.setStored(sub, session); this.dispatchEvent('updated', { sub, ...session }); } async delStored(sub, cause) { await super.delStored(sub, cause); this.dispatchEvent('deleted', { sub, cause }); } /** * @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(sub, refresh) { return this.get(sub, { noCache: refresh === true, allowStale: refresh === false, }); } async get(sub, options) { const session = await this.runtime.usingLock(`@atproto-oauth-client-${sub}`, async () => { const env_1 = { stack: [], error: void 0, hasError: false }; try { // Make sure, even if there is no signal in the options, that the // request will be cancelled after at most 30 seconds. const signal = __addDisposableResource(env_1, (0, util_js_1.timeoutSignal)(30e3, options), false); const abortController = __addDisposableResource(env_1, (0, util_js_1.combineSignals)([options?.signal, signal]), false); return await super.get(sub, { ...options, signal: abortController.signal, }); } catch (e_1) { env_1.error = e_1; env_1.hasError = true; } finally { __disposeResources(env_1); } }); if (sub !== session.tokenSet.sub) { // Fool-proofing (e.g. against invalid session storage) throw new Error('Token set does not match the expected sub'); } return session; } } exports.SessionGetter = SessionGetter; //# sourceMappingURL=session-getter.js.map