// --------------------------------------------------------------------------- // 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), /** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the * authorization grant profiles this Resource Authorization Server * implements. Advertising a profile says only that the server implements * its processing rules; it promises nothing about any particular issuer, * client, or subject being accepted. */ authorization_grant_profiles_supported: Schema.optional(StringArray), }).annotate({ identifier: "OAuthAuthorizationServerMetadata" }); export type OAuthAuthorizationServerMetadata = typeof OAuthAuthorizationServerMetadataSchema.Type; /** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the profile * identifier a Resource Authorization Server advertises when it can process * an Identity Assertion JWT Authorization Grant (ID-JAG). */ export const ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"; /** Whether a Resource Authorization Server advertises the ID-JAG grant profile * (§7.2). This is the ONLY discovery signal that gates enterprise-managed * authorization: a server that stays silent gets the ordinary interactive * flow. It is deliberately not inferred from `grant_types_supported` * containing `jwt-bearer` — that grant type predates this profile and says * nothing about ID-JAG processing rules. */ export const supportsIdJagGrantProfile = ( metadata: Pick, ): boolean => metadata.authorization_grant_profiles_supported?.includes(ID_JAG_GRANT_PROFILE) === true; 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>; }; 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; /** Abort the request after this many ms. Default 20000. */ readonly timeoutMs?: number; /** Send `MCP-Protocol-Version: ` 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>; readonly resourceQueryParams?: Readonly>; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; } const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"; const validateEndpointUrl = ( value: string, label: string, policy: OAuthEndpointUrlPolicy = {}, ): Effect.Effect => 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 => 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 = ( effect: Effect.Effect, options: DiscoveryRequestOptions, ): Effect.Effect => 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> | 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. // --------------------------------------------------------------------------- interface WellKnownCandidate { readonly algorithm: "oauth2" | "oidc"; readonly url: string; } const wellKnownCandidatesFor = ( issuerOrigin: string, issuerPath: string, ): readonly WellKnownCandidate[] => { const hasPath = issuerPath !== "" && issuerPath !== "/"; // Mirrors the library's own well-known composition so the URL we // surface matches what was actually fetched. const insertPath = (suffix: string) => hasPath ? `${issuerOrigin}/.well-known/${suffix}${issuerPath}` : `${issuerOrigin}/.well-known/${suffix}`; const candidates: WellKnownCandidate[] = [ { algorithm: "oauth2", url: insertPath("oauth-authorization-server") }, { algorithm: "oidc", url: insertPath("openid-configuration") }, ]; // OIDC Discovery 1.0 §4 appends the well-known segment to the issuer // instead of inserting it after the origin, and the MCP authorization // spec requires clients to try that form as well. An issuer mounted // under a path may serve only this variant, in which case stopping // after the two path-insertion URLs reports "no metadata" for an // authorization server that is configured correctly. if (hasPath) { candidates.push({ algorithm: "oidc", url: `${issuerOrigin}${issuerPath}/.well-known/openid-configuration`, }); } return candidates; }; 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, url: metadataUrl } of wellKnownCandidatesFor( issuerOrigin, 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 => { const body: Record = { 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 => Effect.gen(function* () { yield* validateEndpointUrl( input.registrationEndpoint, "registration_endpoint", options.endpointUrlPolicy, ).pipe( Effect.mapError( (cause) => new DcrTransport({ detail: "registration_endpoint must use https: or loopback http:", cause, }), ), ); const headers: Record = { "content-type": "application/json", accept: "application/json", }; if (input.initialAccessToken) { headers.authorization = `Bearer ${input.initialAccessToken}`; } let request = HttpClientRequest.post(input.registrationEndpoint).pipe( HttpClientRequest.bodyJsonUnsafe(buildDcrBody(input.metadata)), ); for (const [name, value] of Object.entries(headers)) { request = HttpClientRequest.setHeader(request, name, value); } const response = yield* executeText( request, options, "Dynamic Client Registration request failed", ).pipe( Effect.mapError( (cause) => new DcrTransport({ detail: "Dynamic Client Registration request failed", cause, }), ), ); // Accept both 200 and 201 as success — RFC 7591 mandates 201, but // Todoist (and others) return 200 OK with the client information body. if (response.status !== 200 && response.status !== 201) { return yield* interpretDcrFailure(response.status, response.body); } return yield* decodeClientInformationJson(response.body).pipe( Effect.mapError( (err) => new OAuthDiscoveryError({ message: "Dynamic Client Registration response is malformed", cause: err, }), ), ); }).pipe( Effect.catchTags({ DcrErrorBody: (err) => Effect.fail( new OAuthDiscoveryError({ message: `Dynamic Client Registration failed: ${err.error}${ err.error_description ? ` — ${err.error_description}` : "" }`, status: err.status, error: err.error, errorDescription: err.error_description, cause: err, }), ), DcrTransport: (err) => Effect.fail( new OAuthDiscoveryError({ message: `Dynamic Client Registration failed: ${err.detail}`, status: err.status, cause: err.cause ?? err, }), ), }), ); // --------------------------------------------------------------------------- // RFC 8707 — Resource Indicator canonicalisation // // MCP Authorization 2025-06-18 requires `resource` on /authorize and /token // requests. RFC 8707 §2 says the value is "an absolute URI" identifying the // protected resource — same scheme + host + (optional) path, no fragment, // no query, lowercased scheme/host. // --------------------------------------------------------------------------- export const canonicalResourceUrl = (value: string): string => { const url = new URL(value); const scheme = url.protocol.toLowerCase(); const host = url.host.toLowerCase(); const path = url.pathname.replace(/\/+$/, ""); return `${scheme}//${host}${path}`; }; const validateResourceIndicator = ( value: string, expected: string, ): Effect.Effect => Effect.try({ try: () => canonicalResourceUrl(value), catch: (cause) => new OAuthDiscoveryError({ message: "Protected resource metadata resource is malformed", cause, }), }).pipe( Effect.flatMap((actual) => actual === expected || expected.startsWith(`${actual}/`) ? Effect.succeed(actual) : Effect.fail( new OAuthDiscoveryError({ message: "Protected resource metadata resource does not match requested endpoint", cause: { expected, actual }, }), ), ), ); // --------------------------------------------------------------------------- // Token-endpoint auth method negotiation // // OAuth 2.1 §2.4 leaves the choice to the client; our preference order is // security-first: PKCE-only public client > client_secret_post > // client_secret_basic. Servers like Clay only advertise the secret variants; // servers like most MCP examples only advertise `none`. // --------------------------------------------------------------------------- const SUPPORTED_DCR_AUTH_METHODS = ["none", "client_secret_post", "client_secret_basic"] as const; type DcrAuthMethod = (typeof SUPPORTED_DCR_AUTH_METHODS)[number]; const negotiateAuthMethod = (advertised: readonly string[] | undefined): DcrAuthMethod | null => { if (!advertised || advertised.length === 0) return "none"; for (const candidate of SUPPORTED_DCR_AUTH_METHODS) { if (advertised.includes(candidate)) return candidate; } return null; }; // --------------------------------------------------------------------------- // Convenience: begin the full dynamic flow in one call // --------------------------------------------------------------------------- export interface DynamicAuthorizationState { readonly resourceMetadata: OAuthProtectedResourceMetadata | null; readonly resourceMetadataUrl: string | null; readonly authorizationServerUrl: string; readonly authorizationServerMetadataUrl: string; readonly authorizationServerMetadata: OAuthAuthorizationServerMetadata; readonly clientInformation: OAuthClientInformation; /** RFC 8707 canonical resource URL passed on /authorize and persisted * for the matching /token + refresh calls. */ readonly resource: string; /** Scopes ultimately requested at /authorize. Persisted so refresh * can replay the same set. */ readonly scopes: readonly string[]; } export interface DynamicAuthorizationStartResult { readonly authorizationUrl: string; readonly codeVerifier: string; readonly state: DynamicAuthorizationState; } export interface BeginDynamicAuthorizationInput { readonly endpoint: string; readonly redirectUrl: string; /** RFC 6749 `state` — callers typically pass a per-session random id. */ readonly state: string; /** Defaults: `redirect_uris=[redirectUrl]`, `token_endpoint_auth_method="none"` * (public client + PKCE). */ readonly clientMetadata?: Partial; /** Scopes to request. Defaults to `scopes_supported`; omitted if * neither is set. */ readonly scopes?: readonly string[]; /** Pre-existing state from a previous flow. When provided, the * matching discovery / DCR step is skipped so multi-user sign-ins * against the same source don't re-pay those costs. */ readonly previousState?: { readonly authorizationServerUrl?: string | null; readonly authorizationServerMetadata?: OAuthAuthorizationServerMetadata | null; readonly authorizationServerMetadataUrl?: string | null; readonly resourceMetadata?: OAuthProtectedResourceMetadata | null; readonly resourceMetadataUrl?: string | null; readonly clientInformation?: OAuthClientInformation | null; readonly resource?: string | null; readonly scopes?: readonly string[] | null; }; } export const beginDynamicAuthorization = ( input: BeginDynamicAuthorizationInput, options: DiscoveryRequestOptions = {}, ): Effect.Effect => Effect.gen(function* () { const prior = input.previousState ?? {}; // Skip the resource-metadata probe when we already know (or can // derive) the authorization server URL. Saves two round-trips for // every second-and-later user signing into the same source. const canSkipResourceDiscovery = prior.resourceMetadata !== undefined || !!prior.authorizationServerUrl || !!prior.authorizationServerMetadata; const resource = canSkipResourceDiscovery ? prior.resourceMetadata ? { metadata: prior.resourceMetadata, metadataUrl: prior.resourceMetadataUrl ?? null, } : null : yield* discoverProtectedResourceMetadata(input.endpoint, options); const expectedResource = canonicalResourceUrl(input.endpoint); // RFC 9728 allows multiple authorization_servers — try each in // listed order. Fall back to the endpoint's origin only when no // PRM is advertised. const candidateAuthorizationServerUrls: readonly string[] = (() => { if (prior.authorizationServerUrl) return [prior.authorizationServerUrl]; const fromResource = resource?.metadata.authorization_servers ?? []; if (fromResource.length > 0) return fromResource; const u = new URL(input.endpoint); return [`${u.protocol}//${u.host}`]; })(); const priorAuthServer = prior.authorizationServerMetadata && prior.authorizationServerMetadataUrl ? { metadata: prior.authorizationServerMetadata, metadataUrl: prior.authorizationServerMetadataUrl, url: prior.authorizationServerUrl ?? candidateAuthorizationServerUrls[0]!, } : null; const discovered = priorAuthServer ? priorAuthServer : yield* (() => { const tried: string[] = []; return Effect.gen(function* () { for (const candidate of candidateAuthorizationServerUrls) { tried.push(candidate); const md = yield* discoverAuthorizationServerMetadata(candidate, options).pipe( Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)), ); if (md) { return { metadata: md.metadata, metadataUrl: md.metadataUrl, url: candidate }; } } return yield* new OAuthDiscoveryError({ message: `No OAuth authorization server metadata found (tried: ${tried.join(", ")})`, }); }); })(); const authServer = { metadata: discovered.metadata, metadataUrl: discovered.metadataUrl }; const authorizationServerUrl = discovered.url; const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? []; if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) { return yield* new OAuthDiscoveryError({ message: `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`, }); } const responseTypes = authServer.metadata.response_types_supported ?? []; if (responseTypes.length > 0 && !responseTypes.includes("code")) { return yield* new OAuthDiscoveryError({ message: `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`, }); } // RFC 9728 §2: PRM `scopes_supported` is the resource-scoped list and is // authoritative when present. AS-level `scopes_supported` is global and // (per RFC 9728 §2) "not meant to indicate that an OAuth client should // request all scopes in the list", so we don't auto-expand it. When only // AS-level scopes are advertised we request none and let the AS apply // its default — callers wanting refresh tokens / specific scopes pass // them explicitly via `input.scopes`. const scopes: readonly string[] = input.scopes ?? prior.scopes ?? (resource?.metadata.scopes_supported && resource.metadata.scopes_supported.length > 0 ? resource.metadata.scopes_supported : []); const negotiatedAuthMethod = negotiateAuthMethod( authServer.metadata.token_endpoint_auth_methods_supported, ); if (!negotiatedAuthMethod) { return yield* new OAuthDiscoveryError({ message: `Authorization server does not support a usable token_endpoint_auth_method (advertised: ${( authServer.metadata.token_endpoint_auth_methods_supported ?? [] ).join(", ")})`, }); } // EXPLICIT registration shape (not a fallback of optional input): this DCR // flow always registers the interactive authorization_code + refresh_token // grants and the `code` response type. If the AS rejects refresh_token the // registration request fails loudly rather than silently downgrading. const baseClientMetadata: DynamicClientMetadata = { grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: negotiatedAuthMethod, client_name: "Executor", client_uri: "https://executor.sh", ...(scopes.length > 0 ? { scope: scopes.join(" ") } : {}), ...(input.clientMetadata ?? {}), redirect_uris: input.clientMetadata?.redirect_uris ?? [input.redirectUrl], }; const clientInformation = prior.clientInformation ?? (yield* (() => { const reg = authServer.metadata.registration_endpoint; if (!reg) { return Effect.fail( new OAuthDiscoveryError({ message: "Authorization server does not advertise registration_endpoint — cannot auto-register a client", }), ); } return registerDynamicClient( { registrationEndpoint: reg, metadata: baseClientMetadata }, options, ); })()); const resourceValue = prior.resource ? yield* validateResourceIndicator(prior.resource, expectedResource) : resource?.metadata.resource ? yield* validateResourceIndicator(resource.metadata.resource, expectedResource) : expectedResource; const codeVerifier = createPkceCodeVerifier(); const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier)); const authorizationUrl = buildAuthorizationUrl({ authorizationUrl: authServer.metadata.authorization_endpoint, clientId: clientInformation.client_id, redirectUrl: input.redirectUrl, scopes, state: input.state, codeChallenge, resource: resourceValue, endpointUrlPolicy: options.endpointUrlPolicy, }); return { authorizationUrl, codeVerifier, state: { resourceMetadata: resource?.metadata ?? null, resourceMetadataUrl: resource?.metadataUrl ?? null, authorizationServerUrl, authorizationServerMetadataUrl: authServer.metadataUrl, authorizationServerMetadata: authServer.metadata, clientInformation, resource: resourceValue, scopes, }, }; }); export { createPkceCodeChallenge };