871 lines · 33.6 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// OAuth 2.0 metadata discovery + DCR.//// The token-endpoint helpers in `./oauth-helpers.ts` assume the caller// already knows the authorization/token URLs and client_id — that's// fine for static integrations (Google, a specific OpenAPI server).// The zero-config case — user pastes an arbitrary endpoint URL and we// figure out its OAuth configuration — needs three more building blocks:////   - RFC 9728 Protected Resource Metadata (/.well-known/oauth-protected-resource)//   - RFC 8414 Authorization Server Metadata (/.well-known/oauth-authorization-server,//     with OIDC /.well-known/openid-configuration as fallback)//   - RFC 7591 Dynamic Client Registration (POST `registration_endpoint`)//// The discovery path uses Effect HttpClient throughout so tests can provide// realistic local HTTP services without patching `globalThis.fetch`. A// convenience `beginDynamicAuthorization` chains all three into the single// call callers actually need.// --------------------------------------------------------------------------- import { Data, Duration, Effect, Layer, Option, Predicate, Result, Schema } from "effect";import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import {  OAUTH2_DEFAULT_TIMEOUT_MS,  assertSupportedOAuthEndpointUrl,  buildAuthorizationUrl,  createPkceCodeChallenge,  createPkceCodeVerifier,  type OAuthEndpointUrlPolicy,} from "./oauth-helpers"; // ---------------------------------------------------------------------------// Errors// --------------------------------------------------------------------------- /** Separate tag from `OAuth2Error` so callers can distinguish discovery *  / DCR failures (happen once, before any token round-trips) from *  token-endpoint failures. A plugin's refresh path should never have *  to inspect error messages to tell "metadata drifted, re-discover" *  apart from "refresh token is no longer honoured". */export class OAuthDiscoveryError extends Data.TaggedError("OAuthDiscoveryError")<{  readonly message: string;  readonly status?: number;  /** RFC 6749 §5.2 / RFC 7591 §3.2.2 error code, when the AS returned   *  one (`invalid_client_metadata`, `invalid_redirect_uri`, ...). Lets   *  the HTTP edge surface a structured error to the UI rather than   *  swallow the AS's response into a generic message string. */  readonly error?: string;  readonly errorDescription?: string;  readonly cause?: unknown;}> {} // ---------------------------------------------------------------------------// Schemas (narrow structural parsing — the RFCs leave many fields// optional; we validate only the subset consumers read)// --------------------------------------------------------------------------- const StringArray = Schema.Array(Schema.String); export const OAuthProtectedResourceMetadataSchema = Schema.Struct({  resource: Schema.optional(Schema.String),  authorization_servers: Schema.optional(StringArray),  scopes_supported: Schema.optional(StringArray),  bearer_methods_supported: Schema.optional(StringArray),  resource_documentation: Schema.optional(Schema.String),}).annotate({ identifier: "OAuthProtectedResourceMetadata" });export type OAuthProtectedResourceMetadata = typeof OAuthProtectedResourceMetadataSchema.Type; export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({  issuer: Schema.String,  authorization_endpoint: Schema.String,  token_endpoint: Schema.String,  registration_endpoint: Schema.optional(Schema.String),  client_id_metadata_document_supported: Schema.optional(Schema.Boolean),  scopes_supported: Schema.optional(StringArray),  response_types_supported: Schema.optional(StringArray),  grant_types_supported: Schema.optional(StringArray),  code_challenge_methods_supported: Schema.optional(StringArray),  token_endpoint_auth_methods_supported: Schema.optional(StringArray),  revocation_endpoint: Schema.optional(Schema.String),  introspection_endpoint: Schema.optional(Schema.String),  userinfo_endpoint: Schema.optional(Schema.String),  id_token_signing_alg_values_supported: Schema.optional(StringArray),}).annotate({ identifier: "OAuthAuthorizationServerMetadata" });export type OAuthAuthorizationServerMetadata = typeof OAuthAuthorizationServerMetadataSchema.Type; export type DynamicClientMetadata = {  readonly client_name?: string;  readonly redirect_uris: readonly string[];  readonly grant_types?: readonly string[];  readonly response_types?: readonly string[];  readonly token_endpoint_auth_method?:    | "none"    | "client_secret_basic"    | "client_secret_post"    | "private_key_jwt";  readonly scope?: string;  readonly application_type?: "web" | "native";  readonly client_uri?: string;  readonly logo_uri?: string;  readonly contacts?: readonly string[];  readonly software_id?: string;  readonly software_version?: string;  /** Escape hatch for provider-specific extensions; merged last. */  readonly extra?: Readonly<Record<string, unknown>>;}; export const OAuthClientInformationSchema = Schema.Struct({  client_id: Schema.String,  client_secret: Schema.optional(Schema.String),  client_id_issued_at: Schema.optional(Schema.Number),  client_secret_expires_at: Schema.optional(Schema.Number),  registration_access_token: Schema.optional(Schema.String),  registration_client_uri: Schema.optional(Schema.String),  token_endpoint_auth_method: Schema.optional(Schema.String),  grant_types: Schema.optional(StringArray),  response_types: Schema.optional(StringArray),  redirect_uris: Schema.optional(StringArray),  client_name: Schema.optional(Schema.String),  scope: Schema.optional(Schema.String),}).annotate({ identifier: "OAuthClientInformation" });export type OAuthClientInformation = typeof OAuthClientInformationSchema.Type; const decodeResourceMetadataJson = Schema.decodeUnknownEffect(  Schema.fromJsonString(OAuthProtectedResourceMetadataSchema),);const decodeAuthServerMetadata = Schema.decodeUnknownEffect(OAuthAuthorizationServerMetadataSchema);const decodeClientInformationJson = Schema.decodeUnknownEffect(  Schema.fromJsonString(OAuthClientInformationSchema),); export interface DiscoveryRequestOptions {  /** Injected for tests. Defaults to the platform fetch-backed HttpClient. */  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;  /** Abort the request after this many ms. Default 20000. */  readonly timeoutMs?: number;  /** Send `MCP-Protocol-Version: <value>` on every request. Harmless   *  for non-MCP servers; required by the MCP authorization spec. */  readonly mcpProtocolVersion?: string;  /** Credentials needed to reach the protected resource itself. These   *  are intentionally used only for resource-side probes, never for   *  authorization-server metadata, DCR, authorization, or token calls. */  readonly resourceHeaders?: Readonly<Record<string, string>>;  readonly resourceQueryParams?: Readonly<Record<string, string>>;  readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;} const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"; const validateEndpointUrl = (  value: string,  label: string,  policy: OAuthEndpointUrlPolicy = {},): Effect.Effect<string, OAuthDiscoveryError> =>  Effect.try({    try: () => assertSupportedOAuthEndpointUrl(value, label, policy),    catch: (cause) =>      new OAuthDiscoveryError({        message: `${label} must use https: or loopback http:`,        cause,      }),  }); const validateAuthorizationServerMetadata = (  metadata: OAuthAuthorizationServerMetadata,  policy: OAuthEndpointUrlPolicy = {},): Effect.Effect<void, OAuthDiscoveryError> =>  Effect.gen(function* () {    yield* validateEndpointUrl(metadata.issuer, "issuer", policy);    yield* validateEndpointUrl(metadata.authorization_endpoint, "authorization_endpoint", policy);    yield* validateEndpointUrl(metadata.token_endpoint, "token_endpoint", policy);    if (metadata.registration_endpoint) {      yield* validateEndpointUrl(metadata.registration_endpoint, "registration_endpoint", policy);    }  }); const provideHttpClient = <A, E>(  effect: Effect.Effect<A, E, HttpClient.HttpClient>,  options: DiscoveryRequestOptions,): Effect.Effect<A, E> =>  effect.pipe(Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer)); const executeText = (  request: HttpClientRequest.HttpClientRequest,  options: DiscoveryRequestOptions,  errorMessage: string,): Effect.Effect<{ readonly status: number; readonly body: string }, OAuthDiscoveryError> =>  provideHttpClient(    Effect.gen(function* () {      const client = yield* HttpClient.HttpClient;      const response = yield* client.execute(request).pipe(        Effect.timeoutOrElse({          duration: Duration.millis(options.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS),          orElse: () =>            Effect.fail(              new OAuthDiscoveryError({                message: errorMessage,                cause: "timeout",              }),            ),        }),        Effect.mapError((cause) =>          Predicate.isTagged(cause, "OAuthDiscoveryError")            ? cause            : new OAuthDiscoveryError({                message: errorMessage,                cause,              }),        ),      );      const body = yield* response.text.pipe(        Effect.catch(() => Effect.succeed("")),        Effect.mapError(          (cause) =>            new OAuthDiscoveryError({              message: `${errorMessage}: response body could not be read`,              status: response.status,              cause,            }),        ),      );      return { status: response.status, body };    }),    options,  ); // ---------------------------------------------------------------------------// RFC 9728 — Protected Resource Metadata//// Not covered by `oauth4webapi`. Hand-rolled probe: try the path-scoped// well-known first, then the origin-scoped fallback.// --------------------------------------------------------------------------- const buildResourceMetadataUrls = (resourceUrl: string): string[] => {  const url = new URL(resourceUrl);  const origin = `${url.protocol}//${url.host}`;  const path = url.pathname.replace(/\/+$/, "");  const urls: string[] = [];  if (path && path !== "/") {    urls.push(`${origin}/.well-known/oauth-protected-resource${path}`);  }  urls.push(`${origin}/.well-known/oauth-protected-resource`);  return urls;}; const withResourceQueryParams = (  url: string,  queryParams: Readonly<Record<string, string>> | undefined,): string => {  if (!queryParams || Object.keys(queryParams).length === 0) return url;  const parsed = new URL(url);  for (const [key, value] of Object.entries(queryParams)) {    parsed.searchParams.set(key, value);  }  return parsed.toString();}; export const discoverProtectedResourceMetadata = (  resourceUrl: string,  options: DiscoveryRequestOptions = {},): Effect.Effect<  { readonly metadataUrl: string; readonly metadata: OAuthProtectedResourceMetadata } | null,  OAuthDiscoveryError> =>  Effect.gen(function* () {    for (const url of buildResourceMetadataUrls(resourceUrl)) {      const requestUrl = withResourceQueryParams(url, options.resourceQueryParams);      let request = HttpClientRequest.get(requestUrl).pipe(        HttpClientRequest.setHeader("accept", "application/json"),      );      for (const [name, value] of Object.entries(options.resourceHeaders ?? {})) {        request = HttpClientRequest.setHeader(request, name, value);      }      if (options.mcpProtocolVersion) {        request = HttpClientRequest.setHeader(          request,          MCP_PROTOCOL_VERSION_HEADER,          options.mcpProtocolVersion,        );      }       const result = yield* executeText(        request,        options,        `Failed to fetch protected resource metadata from ${url}`,      );      if (result.status === 404 || result.status === 405 || result.body.length === 0) continue;      if (result.status < 200 || result.status >= 300) {        return yield* new OAuthDiscoveryError({          message: `Protected resource metadata returned status ${result.status}`,          status: result.status,        });      }      const metadata = yield* decodeResourceMetadataJson(result.body).pipe(        Effect.mapError(          (err) =>            new OAuthDiscoveryError({              message: "Protected resource metadata is malformed",              cause: err,            }),        ),      );      return { metadataUrl: url, metadata };    }    return null;  }); // ---------------------------------------------------------------------------// RFC 8414 + OIDC Discovery — Authorization Server Metadata//// Try RFC 8414 (`oauth2`) first and fall back to OIDC Discovery. Keep the// probing in this module so the whole discovery stack shares the same Effect// HttpClient boundary and timeout behavior.// --------------------------------------------------------------------------- const wellKnownUrlFor = (  issuerOrigin: string,  algorithm: "oauth2" | "oidc",  issuerPath: string,): string => {  // Mirrors the library's own well-known composition so the URL we  // surface matches what was actually fetched.  const suffix = algorithm === "oauth2" ? "oauth-authorization-server" : "openid-configuration";  return issuerPath && issuerPath !== "/"    ? `${issuerOrigin}/.well-known/${suffix}${issuerPath}`    : `${issuerOrigin}/.well-known/${suffix}`;}; export const discoverAuthorizationServerMetadata = (  issuer: string,  options: DiscoveryRequestOptions = {},): Effect.Effect<  {    readonly metadataUrl: string;    readonly metadata: OAuthAuthorizationServerMetadata;  } | null,  OAuthDiscoveryError> =>  Effect.gen(function* () {    yield* validateEndpointUrl(issuer, "issuer", options.endpointUrlPolicy);    const issuerUrl = new URL(issuer);    const issuerOrigin = `${issuerUrl.protocol}//${issuerUrl.host}`;    const issuerPath = issuerUrl.pathname.replace(/\/+$/, "");     for (const algorithm of ["oauth2", "oidc"] as const) {      const metadataUrl = wellKnownUrlFor(issuerOrigin, algorithm, issuerPath);      let request = HttpClientRequest.get(metadataUrl).pipe(        HttpClientRequest.setHeader("accept", "application/json"),      );      if (options.mcpProtocolVersion) {        request = HttpClientRequest.setHeader(          request,          MCP_PROTOCOL_VERSION_HEADER,          options.mcpProtocolVersion,        );      }      const result = yield* executeText(        request,        options,        `Discovery (${algorithm}) failed for ${issuer}`,      ).pipe(        Effect.map((response) => {          if (response.status === 404 || response.status === 405) return null;          return response;        }),        // If one algorithm fails mid-roundtrip (network, parse, issuer        // mismatch) we still want to try the other before giving up.        Effect.result,      );       if (Result.isFailure(result)) continue;      const response = result.success;      if (response === null) continue;      if (response.status < 200 || response.status >= 300) continue;       const raw = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(        response.body,      ).pipe(        Effect.mapError(          (err) =>            new OAuthDiscoveryError({              message: "Authorization server metadata is malformed",              cause: err,            }),        ),      );      const metadata = yield* decodeAuthServerMetadata(raw).pipe(        Effect.mapError(          (err) =>            new OAuthDiscoveryError({              message: "Authorization server metadata is malformed",              cause: err,            }),        ),      );      yield* validateAuthorizationServerMetadata(metadata, options.endpointUrlPolicy);      return { metadataUrl, metadata };    }    return null;  }); // ---------------------------------------------------------------------------// RFC 7591 — Dynamic Client Registration//// Hand-rolled instead of delegating to oauth4webapi. The library's// `processDynamicClientRegistrationResponse` requires the AS return// HTTP 201 Created (RFC 7591 §3.2.1), but Todoist (and others) return// 200 OK on success. We accept both, and still surface 4xx OAuth error// envelopes the same way oauth4webapi would.// --------------------------------------------------------------------------- export interface RegisterDynamicClientInput {  readonly registrationEndpoint: string;  readonly metadata: DynamicClientMetadata;  readonly initialAccessToken?: string | null;} // Internal failure modes — collapsed into `OAuthDiscoveryError` at the// boundary. Tagged so we can match without `instanceof`.class DcrErrorBody extends Data.TaggedError("DcrErrorBody")<{  readonly status: number;  readonly error: string;  readonly error_description?: string;}> {} class DcrTransport extends Data.TaggedError("DcrTransport")<{  readonly detail: string;  readonly status?: number;  readonly cause?: unknown;}> {} const DcrErrorBodyJson = Schema.Struct({  error: Schema.String,  error_description: Schema.optional(Schema.String),});const decodeDcrErrorBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(DcrErrorBodyJson)); const buildDcrBody = (m: DynamicClientMetadata): Record<string, unknown> => {  const body: Record<string, unknown> = { redirect_uris: [...m.redirect_uris] };  if (m.client_name !== undefined) body.client_name = m.client_name;  if (m.grant_types !== undefined) body.grant_types = [...m.grant_types];  if (m.response_types !== undefined) body.response_types = [...m.response_types];  if (m.token_endpoint_auth_method !== undefined) {    body.token_endpoint_auth_method = m.token_endpoint_auth_method;  }  if (m.scope !== undefined) body.scope = m.scope;  if (m.application_type !== undefined) body.application_type = m.application_type;  if (m.client_uri !== undefined) body.client_uri = m.client_uri;  if (m.logo_uri !== undefined) body.logo_uri = m.logo_uri;  if (m.contacts !== undefined) body.contacts = [...m.contacts];  if (m.software_id !== undefined) body.software_id = m.software_id;  if (m.software_version !== undefined) body.software_version = m.software_version;  if (m.extra) for (const [k, v] of Object.entries(m.extra)) body[k] = v;  return body;}; const interpretDcrFailure = (status: number, text: string): DcrErrorBody | DcrTransport => {  // RFC 6749 error envelope: `{error, error_description?}` with 4xx.  if (status >= 400 && status < 500) {    const body = text ? decodeDcrErrorBodyJson(text) : null;    return Option.match(body ?? Option.none(), {      onNone: () =>        new DcrTransport({          detail: `Dynamic Client Registration endpoint returned status ${status}${            text ? ` — ${text.slice(0, 200)}` : ""          }`,          status,        }),      onSome: (parsed) =>        parsed.error.length > 0          ? new DcrErrorBody({              status,              error: parsed.error,              error_description: parsed.error_description,            })          : new DcrTransport({              detail: `Dynamic Client Registration endpoint returned status ${status}${                text ? ` — ${text.slice(0, 200)}` : ""              }`,              status,            }),    });  }  return new DcrTransport({    detail: `Dynamic Client Registration endpoint returned status ${status}${      text ? ` — ${text.slice(0, 200)}` : ""    }`,    status,  });}; export const registerDynamicClient = (  input: RegisterDynamicClientInput,  options: DiscoveryRequestOptions = {},): Effect.Effect<OAuthClientInformation, OAuthDiscoveryError> =>  Effect.gen(function* () {    yield* validateEndpointUrl(      input.registrationEndpoint,      "registration_endpoint",

Lines 1–500 of 871.