@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
JavaScript
;
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