@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
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.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