@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
265 lines • 13.4 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;
});
import { CachedGetter, } from '@atproto-labs/simple-store';
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js';
import { TokenInvalidError } from './errors/token-invalid-error.js';
import { TokenRefreshError } from './errors/token-refresh-error.js';
import { TokenRevokedError } from './errors/token-revoked-error.js';
import { OAuthResponseError } from './oauth-response-error.js';
import { combineSignals, timeoutSignal } from './util.js';
export function isExpectedSessionError(err) {
return (err instanceof TokenRefreshError ||
err instanceof TokenRevokedError ||
err instanceof TokenInvalidError ||
err instanceof AuthMethodUnsatisfiableError ||
// The stored session is invalid (e.g. missing properties) and cannot
// be used properly
err instanceof TypeError);
}
/**
* 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).
*/
export class SessionGetter extends CachedGetter {
#serverFactory;
#runtime;
#options;
constructor(sessionStore, serverFactory, runtime, options = {}) {
super(async (sub, { signal }, 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 TokenRefreshError(sub, msg);
await options.onSessionDeleted?.call(null, sub, cause);
throw cause;
}
// @NOTE Throwing a TokenRefreshError (or any other error class defined
// in the deleteOnError options) will result in this.delStored() being
// called.
const { dpopKey, authMethod, tokenSet } = storedSession;
if (sub !== tokenSet.sub) {
// Fool-proofing (e.g. against invalid session storage)
throw new TokenRefreshError(sub, 'Stored session sub mismatch');
}
if (!tokenSet.refresh_token) {
throw new TokenRefreshError(sub, 'No refresh token available');
}
const server = await serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey, { signal });
// 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.
signal?.throwIfAborted();
try {
const newTokenSet = await server.refresh(tokenSet);
if (sub !== newTokenSet.sub) {
// The server returned another sub. Was the tokenSet manipulated?
throw new TokenRefreshError(sub, 'Token set sub mismatch');
}
return {
dpopKey,
tokenSet: newTokenSet,
authMethod: server.authMethod,
};
}
catch (cause) {
// 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. Let's try to recover from concurrency
// issues, or force the session to be deleted by throwing a
// TokenRefreshError.
if (cause instanceof 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 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 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());
},
deleteOnError: isExpectedSessionError,
});
this.#serverFactory = serverFactory;
this.#runtime = runtime;
this.#options = options;
}
async setStored(sub, session) {
// Prevent tampering with the stored value
if (sub !== session.tokenSet.sub) {
throw new Error('Token set does not match the expected sub');
}
try {
await super.setStored(sub, session);
}
catch (err) {
// If we fail to persist the session, we assume that we won't be able to
// fetch it again. We don't want to leave the session in a "limbo" state,
// so we proactively revoke the session and delete it from the store.
const errors = [err];
try {
const { tokenSet, dpopKey, authMethod } = session;
const server = await this.#serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey);
await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token);
}
catch (err) {
errors.push(err);
}
try {
await this.delStored(sub, err);
}
catch (err) {
errors.push(err);
}
throw new AggregateError(errors, `Failed to store session for ${sub}`);
}
await this.#options.onSessionUpdated?.call(null, sub, session);
}
async delStored(sub, cause) {
await super.delStored(sub, cause);
await this.#options.onSessionDeleted?.call(null, sub, cause);
}
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 = timeoutSignal(30e3);
const abortController = __addDisposableResource(env_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;
}
/**
* @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 = 'auto') {
return this.get(sub, {
noCache: refresh === true,
allowStale: refresh === false,
});
}
}
//# sourceMappingURL=session-getter.js.map