UNPKG

@atproto/oauth-client

Version:

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

121 lines (105 loc) 3.59 kB
import { bindFetch, cancelBody, Fetch, FetchResponseError, } from '@atproto-labs/fetch' import { CachedGetter, GetCachedOptions, SimpleStore, } from '@atproto-labs/simple-store' import { OAuthAuthorizationServerMetadata, oauthAuthorizationServerMetadataValidator, oauthIssuerIdentifierSchema, } from '@atproto/oauth-types' import { contentMime } from './util.js' export type { GetCachedOptions, OAuthAuthorizationServerMetadata } export type AuthorizationServerMetadataCache = SimpleStore< string, OAuthAuthorizationServerMetadata > export type OAuthAuthorizationServerMetadataResolverConfig = { allowHttpIssuer?: boolean } /** * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} */ export class OAuthAuthorizationServerMetadataResolver extends CachedGetter< string, OAuthAuthorizationServerMetadata > { private readonly fetch: Fetch<unknown> private readonly allowHttpIssuer: boolean constructor( cache: AuthorizationServerMetadataCache, fetch?: Fetch, config?: OAuthAuthorizationServerMetadataResolverConfig, ) { super(async (issuer, options) => this.fetchMetadata(issuer, options), cache) this.fetch = bindFetch(fetch) this.allowHttpIssuer = config?.allowHttpIssuer === true } async get( input: string, options?: GetCachedOptions, ): Promise<OAuthAuthorizationServerMetadata> { const issuer = oauthIssuerIdentifierSchema.parse(input) if (!this.allowHttpIssuer && issuer.startsWith('http:')) { throw new TypeError( 'Unsecure issuer URL protocol only allowed in development and test environments', ) } return super.get(issuer, options) } private async fetchMetadata( issuer: string, options?: GetCachedOptions, ): Promise<OAuthAuthorizationServerMetadata> { const url = new URL(`/.well-known/oauth-authorization-server`, issuer) const request = new Request(url, { headers: { accept: 'application/json' }, cache: options?.noCache ? 'no-cache' : undefined, signal: options?.signal, redirect: 'manual', // response must be 200 OK }) const response = await this.fetch(request) // https://datatracker.ietf.org/doc/html/rfc8414#section-3.2 if (response.status !== 200) { await cancelBody(response, 'log') throw await FetchResponseError.from( response, `Unexpected status code ${response.status} for "${url}"`, undefined, { cause: request }, ) } if (contentMime(response.headers) !== 'application/json') { await cancelBody(response, 'log') throw await FetchResponseError.from( response, `Unexpected content type for "${url}"`, undefined, { cause: request }, ) } const metadata = oauthAuthorizationServerMetadataValidator.parse( await response.json(), ) // Validate the issuer (MIX-UP attacks) // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks // https://datatracker.ietf.org/doc/html/rfc8414#section-2 if (metadata.issuer !== issuer) { throw new TypeError(`Invalid issuer ${metadata.issuer}`) } // ATPROTO requires client_id_metadata_document // http://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html if (metadata.client_id_metadata_document_supported !== true) { throw new TypeError( `Authorization server "${issuer}" does not support client_id_metadata_document`, ) } return metadata } }