UNPKG

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