@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
326 lines • 15.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OAuthClient = void 0;
const did_resolver_1 = require("@atproto-labs/did-resolver");
const handle_resolver_1 = require("@atproto-labs/handle-resolver");
const identity_resolver_1 = require("@atproto-labs/identity-resolver");
const simple_store_memory_1 = require("@atproto-labs/simple-store-memory");
const jwk_1 = require("@atproto/jwk");
const oauth_types_1 = require("@atproto/oauth-types");
const constants_js_1 = require("./constants.js");
const token_revoked_error_js_1 = require("./errors/token-revoked-error.js");
const oauth_authorization_server_metadata_resolver_js_1 = require("./oauth-authorization-server-metadata-resolver.js");
const oauth_callback_error_js_1 = require("./oauth-callback-error.js");
const oauth_protected_resource_metadata_resolver_js_1 = require("./oauth-protected-resource-metadata-resolver.js");
const oauth_resolver_js_1 = require("./oauth-resolver.js");
const oauth_server_factory_js_1 = require("./oauth-server-factory.js");
const oauth_session_js_1 = require("./oauth-session.js");
const runtime_js_1 = require("./runtime.js");
const session_getter_js_1 = require("./session-getter.js");
const util_js_1 = require("./util.js");
const validate_client_metadata_js_1 = require("./validate-client-metadata.js");
class OAuthClient extends util_js_1.CustomEventTarget {
static async fetchMetadata({ clientId, fetch = globalThis.fetch, signal, }) {
signal?.throwIfAborted();
const request = new Request(clientId, {
redirect: 'error',
signal: signal,
});
const response = await fetch(request);
if (response.status !== 200) {
response.body?.cancel?.();
throw new TypeError(`Failed to fetch client metadata: ${response.status}`);
}
// https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-4.1
const mime = response.headers.get('content-type')?.split(';')[0].trim();
if (mime !== 'application/json') {
response.body?.cancel?.();
throw new TypeError(`Invalid client metadata content type: ${mime}`);
}
const json = await response.json();
signal?.throwIfAborted();
return oauth_types_1.oauthClientMetadataSchema.parse(json);
}
constructor({ fetch = globalThis.fetch, allowHttp = false, stateStore, sessionStore, didCache = undefined, dpopNonceCache = new simple_store_memory_1.SimpleStoreMemory({ ttl: 60e3, max: 100 }), handleCache = undefined, authorizationServerMetadataCache = new simple_store_memory_1.SimpleStoreMemory({
ttl: 60e3,
max: 100,
}), protectedResourceMetadataCache = new simple_store_memory_1.SimpleStoreMemory({
ttl: 60e3,
max: 100,
}), responseMode, clientMetadata, handleResolver, plcDirectoryUrl, runtimeImplementation, keyset, }) {
super();
// Config
Object.defineProperty(this, "clientMetadata", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "responseMode", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "keyset", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// Services
Object.defineProperty(this, "runtime", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "fetch", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "oauthResolver", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "serverFactory", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
// Stores
Object.defineProperty(this, "sessionGetter", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "stateStore", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.keyset = keyset
? keyset instanceof jwk_1.Keyset
? keyset
: new jwk_1.Keyset(keyset)
: undefined;
this.clientMetadata = (0, validate_client_metadata_js_1.validateClientMetadata)(clientMetadata, this.keyset);
this.responseMode = responseMode;
this.runtime = new runtime_js_1.Runtime(runtimeImplementation);
this.fetch = fetch;
this.oauthResolver = new oauth_resolver_js_1.OAuthResolver(new identity_resolver_1.IdentityResolver(new did_resolver_1.DidResolverCached(new did_resolver_1.DidResolverCommon({ fetch, plcDirectoryUrl, allowHttp }), didCache), new handle_resolver_1.CachedHandleResolver(handle_resolver_1.AppViewHandleResolver.from(handleResolver, { fetch }), handleCache)), new oauth_protected_resource_metadata_resolver_js_1.OAuthProtectedResourceMetadataResolver(protectedResourceMetadataCache, fetch, { allowHttpResource: allowHttp }), new oauth_authorization_server_metadata_resolver_js_1.OAuthAuthorizationServerMetadataResolver(authorizationServerMetadataCache, fetch, { allowHttpIssuer: allowHttp }));
this.serverFactory = new oauth_server_factory_js_1.OAuthServerFactory(this.clientMetadata, this.runtime, this.oauthResolver, this.fetch, this.keyset, dpopNonceCache);
this.sessionGetter = new session_getter_js_1.SessionGetter(sessionStore, this.serverFactory, this.runtime);
this.stateStore = stateStore;
// Proxy sessionGetter events
for (const type of ['deleted', 'updated']) {
this.sessionGetter.addEventListener(type, (event) => {
if (!this.dispatchCustomEvent(type, event.detail)) {
event.preventDefault();
}
});
}
}
// Exposed as public API for convenience
get identityResolver() {
return this.oauthResolver.identityResolver;
}
// Exposed as public API for convenience
get didResolver() {
return this.identityResolver.didResolver;
}
// Exposed as public API for convenience
get handleResolver() {
return this.identityResolver.handleResolver;
}
get jwks() {
return this.keyset?.publicJwks ?? { keys: [] };
}
async authorize(input, { signal, ...options } = {}) {
const redirectUri = options?.redirect_uri ?? this.clientMetadata.redirect_uris[0];
if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
// The server will enforce this, but let's catch it early
throw new TypeError('Invalid redirect_uri');
}
const { identity, metadata } = await this.oauthResolver.resolve(input, {
signal,
});
const pkce = await this.runtime.generatePKCE();
const dpopKey = await this.runtime.generateKey(metadata.dpop_signing_alg_values_supported || [constants_js_1.FALLBACK_ALG]);
const state = await this.runtime.generateNonce();
await this.stateStore.set(state, {
iss: metadata.issuer,
dpopKey,
verifier: pkce.verifier,
appState: options?.state,
});
const parameters = {
...options,
client_id: this.clientMetadata.client_id,
redirect_uri: redirectUri,
code_challenge: pkce.challenge,
code_challenge_method: pkce.method,
state,
login_hint: identity
? input // If input is a handle or a DID, use it as a login_hint
: undefined,
response_mode: this.responseMode,
response_type: 'code',
scope: options?.scope ?? this.clientMetadata.scope,
};
const authorizationUrl = new URL(metadata.authorization_endpoint);
// Since the user will be redirected to the authorization_endpoint url using
// a browser, we need to make sure that the url is valid.
if (authorizationUrl.protocol !== 'https:' &&
authorizationUrl.protocol !== 'http:') {
throw new TypeError(`Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`);
}
if (metadata.pushed_authorization_request_endpoint) {
const server = await this.serverFactory.fromMetadata(metadata, dpopKey);
const parResponse = await server.request('pushed_authorization_request', parameters);
authorizationUrl.searchParams.set('client_id', this.clientMetadata.client_id);
authorizationUrl.searchParams.set('request_uri', parResponse.request_uri);
return authorizationUrl;
}
else if (metadata.require_pushed_authorization_requests) {
throw new Error('Server requires pushed authorization requests (PAR) but no PAR endpoint is available');
}
else {
for (const [key, value] of Object.entries(parameters)) {
if (value)
authorizationUrl.searchParams.set(key, String(value));
}
// Length of the URL that will be sent to the server
const urlLength = authorizationUrl.pathname.length + authorizationUrl.search.length;
if (urlLength < 2048) {
return authorizationUrl;
}
else if (!metadata.pushed_authorization_request_endpoint) {
throw new Error('Login URL too long');
}
}
throw new Error('Server does not support pushed authorization requests (PAR)');
}
/**
* This method allows the client to proactively revoke the request_uri it
* created through PAR.
*/
async abortRequest(authorizeUrl) {
const requestUri = authorizeUrl.searchParams.get('request_uri');
if (!requestUri)
return;
// @NOTE This is not implemented here because, 1) the request server should
// invalidate the request_uri after some delay anyways, and 2) I am not sure
// that the revocation endpoint is even supposed to support this (and I
// don't want to spend the time checking now).
// @TODO investigate actual necessity & feasibility of this feature
}
async callback(params) {
const responseJwt = params.get('response');
if (responseJwt != null) {
// https://openid.net/specs/oauth-v2-jarm.html
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'JARM not supported');
}
const issuerParam = params.get('iss');
const stateParam = params.get('state');
const errorParam = params.get('error');
const codeParam = params.get('code');
if (!stateParam) {
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'Missing "state" parameter');
}
const stateData = await this.stateStore.get(stateParam);
if (stateData) {
// Prevent any kind of replay
await this.stateStore.del(stateParam);
}
else {
throw new oauth_callback_error_js_1.OAuthCallbackError(params, `Unknown authorization session "${stateParam}"`);
}
try {
if (errorParam != null) {
throw new oauth_callback_error_js_1.OAuthCallbackError(params, undefined, stateData.appState);
}
if (!codeParam) {
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'Missing "code" query param', stateData.appState);
}
const server = await this.serverFactory.fromIssuer(stateData.iss, stateData.dpopKey);
if (issuerParam != null) {
if (!server.issuer) {
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'Issuer not found in metadata', stateData.appState);
}
if (server.issuer !== issuerParam) {
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'Issuer mismatch', stateData.appState);
}
}
else if (server.serverMetadata.authorization_response_iss_parameter_supported) {
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'iss missing from the response', stateData.appState);
}
const tokenSet = await server.exchangeCode(codeParam, stateData.verifier);
try {
await this.sessionGetter.setStored(tokenSet.sub, {
dpopKey: stateData.dpopKey,
tokenSet,
});
const session = this.createSession(server, tokenSet.sub);
return { session, state: stateData.appState ?? null };
}
catch (err) {
await server.revoke(tokenSet.refresh_token || tokenSet.access_token);
throw err;
}
}
catch (err) {
// Make sure, whatever the underlying error, that the appState is
// available in the calling code
throw oauth_callback_error_js_1.OAuthCallbackError.from(err, params, stateData.appState);
}
}
/**
* Load a stored session. This will refresh the token only if needed (about to
* expire) by default.
*
* @param refresh See {@link SessionGetter.getSession}
*/
async restore(sub, refresh = 'auto') {
// sub arg is lightly typed for convenience of library user
(0, did_resolver_1.assertAtprotoDid)(sub);
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
noCache: refresh === true,
allowStale: refresh === false,
});
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, {
noCache: refresh === true,
allowStale: refresh === false,
});
return this.createSession(server, sub);
}
async revoke(sub) {
// sub arg is lightly typed for convenience of library user
(0, did_resolver_1.assertAtprotoDid)(sub);
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
allowStale: true,
});
// NOT using `;(await this.restore(sub, false)).signOut()` because we want
// the tokens to be deleted even if it was not possible to fetch the issuer
// data.
try {
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey);
await server.revoke(tokenSet.access_token);
}
finally {
await this.sessionGetter.delStored(sub, new token_revoked_error_js_1.TokenRevokedError(sub));
}
}
createSession(server, sub) {
return new oauth_session_js_1.OAuthSession(server, sub, this.sessionGetter, this.fetch);
}
}
exports.OAuthClient = OAuthClient;
//# sourceMappingURL=oauth-client.js.map