UNPKG

@atproto/oauth-client

Version:

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

531 lines (466 loc) 15.8 kB
import { assertAtprotoDid, AtprotoDid, DidCache, DidResolverCached, DidResolverCommon, // eslint-disable-next-line @typescript-eslint/no-unused-vars type DidResolverCommonOptions, } from '@atproto-labs/did-resolver' import { Fetch } from '@atproto-labs/fetch' import { AppViewHandleResolver, CachedHandleResolver, HandleCache, HandleResolver, } from '@atproto-labs/handle-resolver' import { IdentityResolver } from '@atproto-labs/identity-resolver' import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory' import { Key, Keyset } from '@atproto/jwk' import { OAuthAuthorizationRequestParameters, OAuthClientIdDiscoverable, OAuthClientMetadata, OAuthClientMetadataInput, oauthClientMetadataSchema, OAuthResponseMode, } from '@atproto/oauth-types' import { FALLBACK_ALG } from './constants.js' import { TokenRevokedError } from './errors/token-revoked-error.js' import { AuthorizationServerMetadataCache, OAuthAuthorizationServerMetadataResolver, } from './oauth-authorization-server-metadata-resolver.js' import { OAuthCallbackError } from './oauth-callback-error.js' import { OAuthProtectedResourceMetadataResolver, ProtectedResourceMetadataCache, } from './oauth-protected-resource-metadata-resolver.js' import { OAuthResolver } from './oauth-resolver.js' import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js' import { OAuthServerFactory } from './oauth-server-factory.js' import { OAuthSession } from './oauth-session.js' import { RuntimeImplementation } from './runtime-implementation.js' import { Runtime } from './runtime.js' import { SessionEventMap, SessionGetter, SessionStore, } from './session-getter.js' import { InternalStateData, StateStore } from './state-store.js' import { AuthorizeOptions, ClientMetadata } from './types.js' import { CustomEventTarget } from './util.js' import { validateClientMetadata } from './validate-client-metadata.js' // Export all types needed to construct OAuthClientOptions export type { AuthorizationServerMetadataCache, DidCache, DpopNonceCache, Fetch, HandleCache, HandleResolver, InternalStateData, Key, Keyset, OAuthClientMetadata, OAuthClientMetadataInput, OAuthResponseMode, ProtectedResourceMetadataCache, RuntimeImplementation, SessionStore, StateStore, } export type OAuthClientOptions = { // Config responseMode: OAuthResponseMode clientMetadata: Readonly<OAuthClientMetadataInput> keyset?: Keyset | Iterable<Key | undefined | null | false> /** * Determines if the client will allow communicating with the OAuth Servers * (Authorization & Resource), or to retrieve "did:web" documents, over * unsafe HTTP connections. It is recommended to set this to `true` only for * development purposes. * * @note This does not affect the identity resolution mechanism, which will * allow HTTP connections to the PLC Directory (if the provided directory url * is "http:" based). * @default false * @see {@link OAuthProtectedResourceMetadataResolver.allowHttpResource} * @see {@link OAuthAuthorizationServerMetadataResolver.allowHttpIssuer} * @see {@link DidResolverCommonOptions.allowHttp} */ allowHttp?: boolean // Stores stateStore: StateStore sessionStore: SessionStore didCache?: DidCache handleCache?: HandleCache authorizationServerMetadataCache?: AuthorizationServerMetadataCache protectedResourceMetadataCache?: ProtectedResourceMetadataCache dpopNonceCache?: DpopNonceCache // Services handleResolver: HandleResolver | URL | string plcDirectoryUrl?: URL | string runtimeImplementation: RuntimeImplementation fetch?: Fetch } export type OAuthClientEventMap = SessionEventMap export type OAuthClientFetchMetadataOptions = { clientId: OAuthClientIdDiscoverable fetch?: Fetch signal?: AbortSignal } export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> { static async fetchMetadata({ clientId, fetch = globalThis.fetch, signal, }: OAuthClientFetchMetadataOptions) { 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: unknown = await response.json() signal?.throwIfAborted() return oauthClientMetadataSchema.parse(json) } // Config readonly clientMetadata: ClientMetadata readonly responseMode: OAuthResponseMode readonly keyset?: Keyset // Services readonly runtime: Runtime readonly fetch: Fetch readonly oauthResolver: OAuthResolver readonly serverFactory: OAuthServerFactory // Stores protected readonly sessionGetter: SessionGetter protected readonly stateStore: StateStore constructor({ fetch = globalThis.fetch, allowHttp = false, stateStore, sessionStore, didCache = undefined, dpopNonceCache = new SimpleStoreMemory({ ttl: 60e3, max: 100 }), handleCache = undefined, authorizationServerMetadataCache = new SimpleStoreMemory({ ttl: 60e3, max: 100, }), protectedResourceMetadataCache = new SimpleStoreMemory({ ttl: 60e3, max: 100, }), responseMode, clientMetadata, handleResolver, plcDirectoryUrl, runtimeImplementation, keyset, }: OAuthClientOptions) { super() this.keyset = keyset ? keyset instanceof Keyset ? keyset : new Keyset(keyset) : undefined this.clientMetadata = validateClientMetadata(clientMetadata, this.keyset) this.responseMode = responseMode this.runtime = new Runtime(runtimeImplementation) this.fetch = fetch this.oauthResolver = new OAuthResolver( new IdentityResolver( new DidResolverCached( new DidResolverCommon({ fetch, plcDirectoryUrl, allowHttp }), didCache, ), new CachedHandleResolver( AppViewHandleResolver.from(handleResolver, { fetch }), handleCache, ), ), new OAuthProtectedResourceMetadataResolver( protectedResourceMetadataCache, fetch, { allowHttpResource: allowHttp }, ), new OAuthAuthorizationServerMetadataResolver( authorizationServerMetadataCache, fetch, { allowHttpIssuer: allowHttp }, ), ) this.serverFactory = new OAuthServerFactory( this.clientMetadata, this.runtime, this.oauthResolver, this.fetch, this.keyset, dpopNonceCache, ) this.sessionGetter = new SessionGetter( sessionStore, this.serverFactory, this.runtime, ) this.stateStore = stateStore // Proxy sessionGetter events for (const type of ['deleted', 'updated'] as const) { 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: [] as const } as const) } async authorize( input: string, { signal, ...options }: AuthorizeOptions = {}, ): Promise<URL> { 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 || [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: OAuthAuthorizationRequestParameters = { ...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' as const, 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: URL) { 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: URLSearchParams): Promise<{ session: OAuthSession state: string | null }> { const responseJwt = params.get('response') if (responseJwt != null) { // https://openid.net/specs/oauth-v2-jarm.html throw new 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 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 OAuthCallbackError( params, `Unknown authorization session "${stateParam}"`, ) } try { if (errorParam != null) { throw new OAuthCallbackError(params, undefined, stateData.appState) } if (!codeParam) { throw new 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 OAuthCallbackError( params, 'Issuer not found in metadata', stateData.appState, ) } if (server.issuer !== issuerParam) { throw new OAuthCallbackError( params, 'Issuer mismatch', stateData.appState, ) } } else if ( server.serverMetadata.authorization_response_iss_parameter_supported ) { throw new 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 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: string, refresh: boolean | 'auto' = 'auto', ): Promise<OAuthSession> { // sub arg is lightly typed for convenience of library user 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: string) { // sub arg is lightly typed for convenience of library user 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 TokenRevokedError(sub)) } } protected createSession( server: OAuthServerAgent, sub: AtprotoDid, ): OAuthSession { return new OAuthSession(server, sub, this.sessionGetter, this.fetch) } }