UNPKG

@atproto/oauth-client

Version:

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

149 lines 6.19 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.OAuthSession = void 0; const fetch_1 = require("@atproto-labs/fetch"); const token_invalid_error_js_1 = require("./errors/token-invalid-error.js"); const token_revoked_error_js_1 = require("./errors/token-revoked-error.js"); const fetch_dpop_js_1 = require("./fetch-dpop.js"); const ReadableStream = globalThis.ReadableStream; class OAuthSession { constructor(server, sub, sessionGetter, fetch = globalThis.fetch) { Object.defineProperty(this, "server", { enumerable: true, configurable: true, writable: true, value: server }); Object.defineProperty(this, "sub", { enumerable: true, configurable: true, writable: true, value: sub }); Object.defineProperty(this, "sessionGetter", { enumerable: true, configurable: true, writable: true, value: sessionGetter }); 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: server.clientMetadata.client_id, key: server.dpopKey, supportedAlgs: server.serverMetadata.dpop_signing_alg_values_supported, sha256: async (v) => server.runtime.sha256(v), nonces: server.dpopNonces, isAuthServer: false, }); } get did() { return this.sub; } get serverMetadata() { return this.server.serverMetadata; } /** * @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 getTokenSet(refresh) { const { tokenSet } = await this.sessionGetter.get(this.sub, { noCache: refresh === true, allowStale: refresh === false, }); return tokenSet; } async getTokenInfo(refresh = 'auto') { const tokenSet = await this.getTokenSet(refresh); const expiresAt = tokenSet.expires_at == null ? undefined : new Date(tokenSet.expires_at); return { expiresAt, get expired() { return expiresAt == null ? undefined : expiresAt.getTime() < Date.now() - 5e3; }, scope: tokenSet.scope, iss: tokenSet.iss, aud: tokenSet.aud, sub: tokenSet.sub, }; } async signOut() { try { const tokenSet = await this.getTokenSet(false); await this.server.revoke(tokenSet.access_token); } finally { await this.sessionGetter.delStored(this.sub, new token_revoked_error_js_1.TokenRevokedError(this.sub)); } } async fetchHandler(pathname, init) { // This will try and refresh the token if it is known to be expired const tokenSet = await this.getTokenSet('auto'); const initialUrl = new URL(pathname, tokenSet.aud); const initialAuth = `${tokenSet.token_type} ${tokenSet.access_token}`; const headers = new Headers(init?.headers); headers.set('Authorization', initialAuth); const initialResponse = await this.dpopFetch(initialUrl, { ...init, headers, }); // If the token is not expired, we don't need to refresh it if (!isInvalidTokenResponse(initialResponse)) { return initialResponse; } let tokenSetFresh; try { // Force a refresh tokenSetFresh = await this.getTokenSet(true); } catch (err) { return initialResponse; } // The stream was already consumed. We cannot retry the request. A solution // would be to tee() the input stream but that would bufferize the entire // stream in memory which can lead to memory starvation. Instead, we will // return the original response and let the calling code handle retries. if (ReadableStream && init?.body instanceof ReadableStream) { return initialResponse; } const finalAuth = `${tokenSetFresh.token_type} ${tokenSetFresh.access_token}`; const finalUrl = new URL(pathname, tokenSetFresh.aud); headers.set('Authorization', finalAuth); const finalResponse = await this.dpopFetch(finalUrl, { ...init, headers }); // The token was successfully refreshed, but is still not accepted by the // resource server. This might be due to the resource server not accepting // credentials from the authorization server (e.g. because some migration // occurred). Any ways, there is no point in keeping the session. if (isInvalidTokenResponse(finalResponse)) { // TODO: Is there a "softer" way to handle this, e.g. by marking the // session as "expired" in the session store, allowing the user to trigger // a new login (using login_hint)? await this.sessionGetter.delStored(this.sub, new token_invalid_error_js_1.TokenInvalidError(this.sub)); } return finalResponse; } } exports.OAuthSession = OAuthSession; /** * @see {@link https://datatracker.ietf.org/doc/html/rfc6750#section-3} * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no} */ function isInvalidTokenResponse(response) { if (response.status !== 401) return false; const wwwAuth = response.headers.get('WWW-Authenticate'); return (wwwAuth != null && (wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) && wwwAuth.includes('error="invalid_token"')); } //# sourceMappingURL=oauth-session.js.map