// ---------------------------------------------------------------------------// OAuth service implementation — the runtime behind `executor.oauth` and// `ctx.oauth`.//// v2 model: a client is a registered app carrying its own endpoints; running// its flow mints a Connection. The client + in-flight session rows are// owner-scoped core tables; minted access tokens persist through the default// writable credential provider; tools are produced by `mintOAuthConnection`// (which the executor wires to the connection-create + tool-production path).//// Milestone 2: `start` / `complete` are wired. `start` generates PKCE + a// branded state, persists an `oauth_session`, and returns the authorize URL// (authorization_code) or exchanges client credentials immediately. `complete`// redeems the session, exchanges the code, and mints the connection.// --------------------------------------------------------------------------- import { Duration, Effect, Layer, Option, Schema } from "effect";import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import type { Connection } from "./connection";import type { IFumaClient, StorageFailure } from "./fuma-runtime";import { StorageError } from "./fuma-runtime";import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, OAuthState, Owner, ProviderItemId,} from "./ids";import { OAuthCompleteError, OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, type ConnectResult, type CreateOAuthClientInput, type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, type OAuthGrant, type OAuthProbeInput, type OAuthProbeResult, type OAuthService, type OAuthStartInput, type RegisterDynamicClientInput,} from "./oauth-client";import type { OwnerBinding } from "./plugin";import type { CredentialProvider } from "./provider";import { discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, OAuthDiscoveryError, registerDynamicClient as registerDynamicClientDcr,} from "./oauth-discovery";import { assertSupportedOAuthEndpointUrl, buildAuthorizationUrl, providerAuthorizeExtras, createOAuthState, createPkceCodeChallenge, createPkceCodeVerifier, exchangeAuthorizationCode, exchangeClientCredentials, isLoopbackHttpUrl, rebindTokenEndpointHostToCallbackDomain, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy,} from "./oauth-helpers";import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth"; /** Connection-minting input for the OAuth flow — extends a connection create * with the OAuth lifecycle fields (client slug, refresh material, expiry, * granted scope). The executor's `mintOAuthConnection` writes these onto the * `connection` row and produces the connection's tools. */export interface MintOAuthConnectionInput { readonly owner: Owner; readonly name: ConnectionName; readonly integration: IntegrationSlug; readonly template: AuthTemplateSlug; readonly identityLabel?: string | null; /** Credential provider key + item id the access token is stored under. */ readonly provider: string; readonly itemId: string; readonly oauthClient: OAuthClientSlug; /** The owner of `oauthClient` (persisted so refresh loads it by explicit owner). */ readonly oauthClientOwner: Owner; readonly refreshItemId: string | null; readonly expiresAt: number | null; readonly oauthScope: string | null; /** Per-connection override for the token endpoint, persisted only when the * code was redeemed at a region other than the client's configured token * host (Datadog multi-site). Null means refresh uses the client's token URL. */ readonly oauthTokenUrl?: string | null;} /** The OAuth scope policy for a `(integration, template)`. Either the * integration declares the scopes to request (`scopes`, possibly empty — an * empty set requests no scopes), or it declares none and the request scopes * are discovered from the server's metadata at connect (`discover`, used by * MCP). The two are mutually exclusive by construction. */export type OAuthScopePolicy = | { readonly kind: "scopes"; readonly scopes: readonly string[] } | { readonly kind: "discover" }; /** Everything the OAuth service needs from the executor: fuma access for the * owned `oauth_client` / `oauth_session` tables, the default credential * provider for minted tokens, a `mintOAuthConnection` callback (writes the * connection row + produces tools), the owner binding, and the redirect base. */export interface OAuthServiceDeps { readonly fuma: IFumaClient; readonly owner: OwnerBinding; readonly tenant: string; readonly subject: string | null; readonly ownedKeys: (owner: Owner) => { readonly tenant: string; readonly owner: Owner; readonly subject: string; }; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( input: MintOAuthConnectionInput, ) => Effect.Effect<Connection, StorageFailure>; /** * Resolve the OAuth scope policy for a `(integration, template)`: * - `{ kind: "scopes", scopes }`: the scopes the integration's auth template * DECLARES (e.g. an OpenAPI bundle's authentication-template scope union), * NOT the scopes frozen on a specific `oauth_client` row. These are * requested verbatim at connect (`start`); an empty set requests none. * - `{ kind: "discover" }`: the integration declares no scopes, so `start` * discovers the request scopes from the server's RFC 9728 / RFC 8414 * metadata. Used by server-targeting integrations (MCP) whose scopes live * on the server rather than in a template. */ readonly resolveOAuthScopePolicy: ( integration: IntegrationSlug, template: AuthTemplateSlug, ) => Effect.Effect<OAuthScopePolicy, StorageFailure>; readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; readonly fetch?: typeof globalThis.fetch; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * The OAuth callback URL (`${webBaseUrl}${mountPrefix}/oauth/callback`) the host * serves and sends to providers on every authorization request + DCR registration. * The path carries the host's API mount prefix (cloud: `/api`; root-mounted * hosts like local: none), so it matches the route that serves the callback. * * REQUIRED and EXPLICIT — there is no localhost default. Pass `null` only when * the host genuinely has no redirect callback (e.g. a pure client-credentials * or non-HTTP context); the redirect-requiring flows (`start` for * `authorization_code`, `registerDynamicClient`) then fail loudly instead of * silently handing the provider a wrong `http://127.0.0.1/callback`. Hosts * that serve OAuth MUST derive this from the request origin / web base URL. */ readonly redirectUri: string | null; /** URL selected organization slug to round-trip through OAuth `state`. */ readonly callbackStateOrgSlug?: string | null;} type LooseDb = { readonly create: (name: string, value: Record<string, unknown>) => Promise<unknown>; readonly deleteMany: (name: string, options: unknown) => Promise<void>; readonly findFirst: (name: string, options: unknown) => Promise<Record<string, unknown> | null>; readonly findMany: ( name: string, options: unknown, ) => Promise<readonly Record<string, unknown>[]>;};const looseDb = (db: unknown): LooseDb => db as LooseDb; /** Where an OAuth-minted access token is stored in the default provider. The * refresh token lives at the same id with a `:refresh` suffix. */const accessItemId = (owner: Owner, integration: IntegrationSlug, name: ConnectionName): string => `oauth:${owner}:${integration}:${name}`;const refreshItemIdFor = (accessId: string): string => `${accessId}:refresh`; /** Order-preserving de-duplication of a scope list. */const dedupeScopes = (scopes: readonly string[]): readonly string[] => [...new Set(scopes)]; const intersectScopes = ( requested: readonly string[], supported: readonly string[] | undefined,): readonly string[] => { if (!supported || supported.length === 0) return requested; const supportedSet = new Set(supported); return requested.filter((scope) => supportedSet.has(scope));}; const recordedOAuthScope = ( token: OAuth2TokenResponse, requestedScopes: readonly string[],): string | null => { if (token.scope == null) return requestedScopes.join(" ") || null; const granted = token.scope.split(/\s+/).filter(Boolean); const coveredByRefreshToken = token.refresh_token && requestedScopes.includes("offline_access") ? ["offline_access"] : []; const recorded = dedupeScopes([...granted, ...coveredByRefreshToken]); return recorded.join(" ") || null;}; const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); /** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The * jsonColumn may surface as a parsed object (in-memory backends) or a JSON * string (serialized backends); decode strings before reading. Returns `null` * for legacy sessions written before `requestedScopes` was persisted, so * `complete` can fall back to the client's scopes. */const requestedScopesFromPayload = (payload: unknown): readonly string[] | null => { const decoded = typeof payload === "string" ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) : payload; if (decoded === null || typeof decoded !== "object") return null; const value = (decoded as Record<string, unknown>).requestedScopes; return Array.isArray(value) ? value.filter((s): s is string => typeof s === "string") : null;}; /** Read the app owner `start` recorded on the session payload. Null when absent * (same-owner connects, or sessions written before this field), so `complete` * falls back to the session owner. */const clientOwnerFromPayload = (payload: unknown): Owner | null => { const decoded = typeof payload === "string" ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) : payload; if (decoded === null || typeof decoded !== "object") return null; const value = (decoded as Record<string, unknown>).clientOwner; return value === "user" || value === "org" ? value : null;}; /** Narrow a stored `grant` string to the `OAuthGrant` union, or `null` when the * value is neither known grant. EXPLICIT — there is no silent fallback to * `authorization_code`; an unknown grant means a corrupt row and callers that * drive token exchange (`loadClient`) must fail loudly rather than guessing. */const parseGrant = (grant: unknown): OAuthGrant | null => grant === "client_credentials" || grant === "authorization_code" ? grant : null; const parseOAuthClientOrigin = (row: { readonly slug?: unknown; readonly grant?: unknown; readonly resource?: unknown; readonly origin_kind?: unknown; readonly origin_integration?: unknown;}): OAuthClientOrigin => { if (row.origin_kind === "dynamic_client_registration") { return { kind: "dynamic_client_registration", integration: row.origin_integration == null ? null : IntegrationSlug.make(String(row.origin_integration)), }; } const slug = row.slug == null ? "" : String(row.slug); const resource = row.resource == null ? "" : String(row.resource); if ( row.origin_kind == null && row.grant === "authorization_code" && /(^|[-_])mcp($|[-_])/.test(slug) && /(^|\/)mcp($|[/?#])/.test(resource) ) { return { kind: "dynamic_client_registration", integration: null }; } return { kind: "manual" };}; interface LoadedOAuthClient { readonly slug: string; readonly authorizationUrl: string; readonly tokenUrl: string; readonly grant: OAuthGrant; readonly clientId: string; /** Resolved literal secret (read from the provider via the stored item id). */ readonly clientSecret: string; readonly resource: string | null;} /** Where an OAuth app's client secret is stored in the default writable * provider — derived solely from the app's (owner, slug) identity. */const clientSecretItemId = (owner: Owner, slug: OAuthClientSlug): string => `oauth-client:${owner}:${slug}:secret`; const expiresAtFrom = (token: OAuth2TokenResponse): number | null => typeof token.expires_in === "number" ? Date.now() + token.expires_in * 1000 : null; /** Error message surfaced when a redirect-requiring OAuth flow runs on an * executor that was constructed without a `redirectUri`. Previously this path * silently used `http://127.0.0.1/callback`, which providers stored as the * client's callback and then rejected (or worse, accepted, handing tokens to * localhost). Fail loudly so the misconfiguration is caught at the call site. */const REDIRECT_URI_REQUIRED_MESSAGE = "OAuth redirect flow requires a configured redirectUri, but none was provided " + "to the executor. Pass `redirectUri` to createExecutor (hosts derive it from " + "the web base URL / request origin as `${webBaseUrl}${mountPrefix}/oauth/callback`)."; const canonicalUrlString = (value: string): string => { const url = new URL(value.trim()); url.hash = ""; return url.toString();}; const isWellKnownOAuthMetadataUrl = (value: string): boolean => { const path = new URL(value.trim()).pathname.toLowerCase(); return ( path.includes("/.well-known/oauth-authorization-server") || path.includes("/.well-known/openid-configuration") || path.includes("/.well-known/oauth-protected-resource") );}; const validateSupportedEndpoint = ( value: string, label: string, endpointUrlPolicy: OAuthEndpointUrlPolicy | undefined,): Effect.Effect<void, StorageFailure> => Effect.try({ try: () => assertSupportedOAuthEndpointUrl(value, label, endpointUrlPolicy), catch: (cause) => new StorageError({ message: `Invalid OAuth client endpoint configuration: ${label} must use https: or loopback http:.`, cause, }), }).pipe(Effect.asVoid); const validateClientEndpoints = ( input: CreateOAuthClientInput, endpointUrlPolicy: OAuthEndpointUrlPolicy | undefined,): Effect.Effect<void, StorageFailure> => Effect.gen(function* () { yield* validateSupportedEndpoint(input.tokenUrl, "token_url", endpointUrlPolicy); if (input.resource != null && input.resource.trim().length > 0) { yield* validateSupportedEndpoint(input.resource, "resource", endpointUrlPolicy); } if (input.grant !== "authorization_code") return; yield* validateSupportedEndpoint( input.authorizationUrl, "authorization_url", endpointUrlPolicy, ); if (isWellKnownOAuthMetadataUrl(input.authorizationUrl)) { return yield* new StorageError({ message: "Invalid OAuth client endpoint configuration: authorization_url must be the OAuth authorization endpoint, not a .well-known metadata URL.", cause: undefined, }); } if (canonicalUrlString(input.authorizationUrl) === canonicalUrlString(input.tokenUrl)) { return yield* new StorageError({ message: "Invalid OAuth client endpoint configuration: authorization_url must not equal token_url.", cause: undefined, }); } }); export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; const fetch = deps.fetch; // EXPLICIT — no localhost default. `null` means this executor has no OAuth // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. const redirectUri = deps.redirectUri; const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy }; const filterAuthorizationCodeScopes = ( client: LoadedOAuthClient, requestedScopes: readonly string[], ): Effect.Effect<readonly string[], never> => Effect.gen(function* () { if (requestedScopes.length === 0) return requestedScopes; const resource = client.resource ? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe( Effect.catch(() => Effect.succeed(null)), Effect.provide(httpClientLayer), ) : null; const issuer = resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin; const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe( Effect.catch(() => Effect.succeed(null)), Effect.provide(httpClientLayer), ); return intersectScopes(requestedScopes, as?.metadata.scopes_supported); }).pipe(Effect.catch(() => Effect.succeed(requestedScopes))); // Caps on server-controlled discovery input — a hostile or buggy server must // not be able to hang `oauth.start` or overflow the authorize URL. const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template const capScopes = (scopes: readonly string[]): readonly string[] => dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); // Discover the scopes to request when the integration declares none — only // reached for integrations that opt in (MCP-style). The resource's own RFC // 9728 `scopes_supported` is authoritative when present, even when empty (§2 // defines the field; §7.2 cautions against requesting more than it lists). // Only when the resource is SILENT do we read the scopes advertised by the // authorization servers it NAMES (RFC 8414) — we never probe arbitrary URLs. const discoverScopesForResource = ( resource: string | null, ): Effect.Effect<readonly string[], OAuthDiscoveryError> => Effect.gen(function* () { if (resource == null) { return yield* new OAuthDiscoveryError({ message: "Cannot discover OAuth scopes: the client has no resource configured", }); } // `httpClientLayer` flows through `options` so discovery uses the host's // configured client (discovery self-provides from `options.httpClientLayer`). const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer }; const protectedResource = yield* discoverProtectedResourceMetadata( resource, discoveryOptions, ); const resourceScopes = protectedResource?.metadata.scopes_supported; if (resourceScopes !== undefined) return capScopes(resourceScopes); // The resource is silent on scopes — read them from the authorization // servers it names, in order. An advertised list is authoritative even // when empty. Any AS we cannot read clean RFC 8414 metadata from — // unreachable, 404, malformed, or issuer-mismatched — contributes nothing // and we move on (mirroring the dynamic-registration discovery path); if // none advertise scopes we request none and let the AS apply its defaults // (RFC 8414 metadata is optional, so its absence is not a failure). The // list is server-controlled, so cap how many of its hosts we probe. for (const issuer of (protectedResource?.metadata.authorization_servers ?? []).slice( 0, MAX_DISCOVERY_AUTH_SERVERS, )) { const authServer = yield* discoverAuthorizationServerMetadata( issuer, discoveryOptions, ).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null))); const scopes = authServer?.metadata.scopes_supported; if (scopes !== undefined) return capScopes(scopes); } return []; }).pipe( // Bound the whole sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS // fetches, each with its own request timeout). 30s is larger than a single // request timeout so it bounds the sequence, not a slow-but-valid request. Effect.timeoutOrElse({ duration: Duration.seconds(30), orElse: () => Effect.fail( new OAuthDiscoveryError({ message: "OAuth scope discovery timed out", cause: "timeout", }), ), }), ); // ----------------------------------------------------------------------- // createClient — write the oauth_client row. // ----------------------------------------------------------------------- const createClient = ( input: CreateOAuthClientInput, ): Effect.Effect<OAuthClientSlug, StorageFailure> => Effect.gen(function* () { yield* validateClientEndpoints(input, deps.endpointUrlPolicy); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => new StorageError({ message: "Cannot write oauth_client for owner without a subject", cause, }), }); const now = new Date(); // Store the secret out-of-band in the default writable provider; the row // keeps only its item id. A public/PKCE client (empty secret) stores null // — there is no plaintext column to fall back to (the schema dropped it). let clientSecretItemIdValue: string | null = null; if (input.clientSecret.length > 0) { const provider = deps.defaultWritableProvider(); if (!provider || !provider.set) { return yield* new StorageError({ message: "No default writable credential provider is registered to store the OAuth client secret.", cause: undefined, }); } clientSecretItemIdValue = clientSecretItemId(input.owner, input.slug); yield* provider.set(ProviderItemId.make(clientSecretItemIdValue), input.clientSecret); } yield* deps.fuma .use("oauth_client.deleteExisting", (db) => looseDb(db).deleteMany("oauth_client", { where: (b: any) => b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), }), )Lines 1–500 of 1188. Load more
// --------------------------------------------------------------------------- // OAuth service implementation — the runtime behind `executor.oauth` and // `ctx.oauth`. // // v2 model: a client is a registered app carrying its own endpoints; running // its flow mints a Connection. The client + in-flight session rows are // owner-scoped core tables; minted access tokens persist through the default // writable credential provider; tools are produced by `mintOAuthConnection` // (which the executor wires to the connection-create + tool-production path). // // Milestone 2: `start` / `complete` are wired. `start` generates PKCE + a // branded state, persists an `oauth_session`, and returns the authorize URL // (authorization_code) or exchanges client credentials immediately. `complete` // redeems the session, exchanges the code, and mints the connection. // --------------------------------------------------------------------------- import { Duration, Effect, Layer, Option, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import type { Connection } from "./connection"; import type { IFumaClient, StorageFailure } from "./fuma-runtime"; import { StorageError } from "./fuma-runtime"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, OAuthState, Owner, ProviderItemId, } from "./ids"; import { OAuthCompleteError, OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, type ConnectResult, type CreateOAuthClientInput, type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, type OAuthGrant, type OAuthProbeInput, type OAuthProbeResult, type OAuthService, type OAuthStartInput, type RegisterDynamicClientInput, } from "./oauth-client"; import type { OwnerBinding } from "./plugin"; import type { CredentialProvider } from "./provider"; import { discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, OAuthDiscoveryError, registerDynamicClient as registerDynamicClientDcr, } from "./oauth-discovery"; import { assertSupportedOAuthEndpointUrl, buildAuthorizationUrl, providerAuthorizeExtras, createOAuthState, createPkceCodeChallenge, createPkceCodeVerifier, exchangeAuthorizationCode, exchangeClientCredentials, isLoopbackHttpUrl, rebindTokenEndpointHostToCallbackDomain, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth"; /** Connection-minting input for the OAuth flow — extends a connection create * with the OAuth lifecycle fields (client slug, refresh material, expiry, * granted scope). The executor's `mintOAuthConnection` writes these onto the * `connection` row and produces the connection's tools. */ export interface MintOAuthConnectionInput { readonly owner: Owner; readonly name: ConnectionName; readonly integration: IntegrationSlug; readonly template: AuthTemplateSlug; readonly identityLabel?: string | null; /** Credential provider key + item id the access token is stored under. */ readonly provider: string; readonly itemId: string; readonly oauthClient: OAuthClientSlug; /** The owner of `oauthClient` (persisted so refresh loads it by explicit owner). */ readonly oauthClientOwner: Owner; readonly refreshItemId: string | null; readonly expiresAt: number | null; readonly oauthScope: string | null; /** Per-connection override for the token endpoint, persisted only when the * code was redeemed at a region other than the client's configured token * host (Datadog multi-site). Null means refresh uses the client's token URL. */ readonly oauthTokenUrl?: string | null; } /** The OAuth scope policy for a `(integration, template)`. Either the * integration declares the scopes to request (`scopes`, possibly empty — an * empty set requests no scopes), or it declares none and the request scopes * are discovered from the server's metadata at connect (`discover`, used by * MCP). The two are mutually exclusive by construction. */ export type OAuthScopePolicy = | { readonly kind: "scopes"; readonly scopes: readonly string[] } | { readonly kind: "discover" }; /** Everything the OAuth service needs from the executor: fuma access for the * owned `oauth_client` / `oauth_session` tables, the default credential * provider for minted tokens, a `mintOAuthConnection` callback (writes the * connection row + produces tools), the owner binding, and the redirect base. */ export interface OAuthServiceDeps { readonly fuma: IFumaClient; readonly owner: OwnerBinding; readonly tenant: string; readonly subject: string | null; readonly ownedKeys: (owner: Owner) => { readonly tenant: string; readonly owner: Owner; readonly subject: string; }; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( input: MintOAuthConnectionInput, ) => Effect.Effect<Connection, StorageFailure>; /** * Resolve the OAuth scope policy for a `(integration, template)`: * - `{ kind: "scopes", scopes }`: the scopes the integration's auth template * DECLARES (e.g. an OpenAPI bundle's authentication-template scope union), * NOT the scopes frozen on a specific `oauth_client` row. These are * requested verbatim at connect (`start`); an empty set requests none. * - `{ kind: "discover" }`: the integration declares no scopes, so `start` * discovers the request scopes from the server's RFC 9728 / RFC 8414 * metadata. Used by server-targeting integrations (MCP) whose scopes live * on the server rather than in a template. */ readonly resolveOAuthScopePolicy: ( integration: IntegrationSlug, template: AuthTemplateSlug, ) => Effect.Effect<OAuthScopePolicy, StorageFailure>; readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; readonly fetch?: typeof globalThis.fetch; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * The OAuth callback URL (`${webBaseUrl}${mountPrefix}/oauth/callback`) the host * serves and sends to providers on every authorization request + DCR registration. * The path carries the host's API mount prefix (cloud: `/api`; root-mounted * hosts like local: none), so it matches the route that serves the callback. * * REQUIRED and EXPLICIT — there is no localhost default. Pass `null` only when * the host genuinely has no redirect callback (e.g. a pure client-credentials * or non-HTTP context); the redirect-requiring flows (`start` for * `authorization_code`, `registerDynamicClient`) then fail loudly instead of * silently handing the provider a wrong `http://127.0.0.1/callback`. Hosts * that serve OAuth MUST derive this from the request origin / web base URL. */ readonly redirectUri: string | null; /** URL selected organization slug to round-trip through OAuth `state`. */ readonly callbackStateOrgSlug?: string | null; } type LooseDb = { readonly create: (name: string, value: Record<string, unknown>) => Promise<unknown>; readonly deleteMany: (name: string, options: unknown) => Promise<void>; readonly findFirst: (name: string, options: unknown) => Promise<Record<string, unknown> | null>; readonly findMany: ( name: string, options: unknown, ) => Promise<readonly Record<string, unknown>[]>; }; const looseDb = (db: unknown): LooseDb => db as LooseDb; /** Where an OAuth-minted access token is stored in the default provider. The * refresh token lives at the same id with a `:refresh` suffix. */ const accessItemId = (owner: Owner, integration: IntegrationSlug, name: ConnectionName): string => `oauth:${owner}:${integration}:${name}`; const refreshItemIdFor = (accessId: string): string => `${accessId}:refresh`; /** Order-preserving de-duplication of a scope list. */ const dedupeScopes = (scopes: readonly string[]): readonly string[] => [...new Set(scopes)]; const intersectScopes = ( requested: readonly string[], supported: readonly string[] | undefined, ): readonly string[] => { if (!supported || supported.length === 0) return requested; const supportedSet = new Set(supported); return requested.filter((scope) => supportedSet.has(scope)); }; const recordedOAuthScope = ( token: OAuth2TokenResponse, requestedScopes: readonly string[], ): string | null => { if (token.scope == null) return requestedScopes.join(" ") || null; const granted = token.scope.split(/\s+/).filter(Boolean); const coveredByRefreshToken = token.refresh_token && requestedScopes.includes("offline_access") ? ["offline_access"] : []; const recorded = dedupeScopes([...granted, ...coveredByRefreshToken]); return recorded.join(" ") || null; }; const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); /** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The * jsonColumn may surface as a parsed object (in-memory backends) or a JSON * string (serialized backends); decode strings before reading. Returns `null` * for legacy sessions written before `requestedScopes` was persisted, so * `complete` can fall back to the client's scopes. */ const requestedScopesFromPayload = (payload: unknown): readonly string[] | null => { const decoded = typeof payload === "string" ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) : payload; if (decoded === null || typeof decoded !== "object") return null; const value = (decoded as Record<string, unknown>).requestedScopes; return Array.isArray(value) ? value.filter((s): s is string => typeof s === "string") : null; }; /** Read the app owner `start` recorded on the session payload. Null when absent * (same-owner connects, or sessions written before this field), so `complete` * falls back to the session owner. */ const clientOwnerFromPayload = (payload: unknown): Owner | null => { const decoded = typeof payload === "string" ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) : payload; if (decoded === null || typeof decoded !== "object") return null; const value = (decoded as Record<string, unknown>).clientOwner; return value === "user" || value === "org" ? value : null; }; /** Narrow a stored `grant` string to the `OAuthGrant` union, or `null` when the * value is neither known grant. EXPLICIT — there is no silent fallback to * `authorization_code`; an unknown grant means a corrupt row and callers that * drive token exchange (`loadClient`) must fail loudly rather than guessing. */ const parseGrant = (grant: unknown): OAuthGrant | null => grant === "client_credentials" || grant === "authorization_code" ? grant : null; const parseOAuthClientOrigin = (row: { readonly slug?: unknown; readonly grant?: unknown; readonly resource?: unknown; readonly origin_kind?: unknown; readonly origin_integration?: unknown; }): OAuthClientOrigin => { if (row.origin_kind === "dynamic_client_registration") { return { kind: "dynamic_client_registration", integration: row.origin_integration == null ? null : IntegrationSlug.make(String(row.origin_integration)), }; } const slug = row.slug == null ? "" : String(row.slug); const resource = row.resource == null ? "" : String(row.resource); if ( row.origin_kind == null && row.grant === "authorization_code" && /(^|[-_])mcp($|[-_])/.test(slug) && /(^|\/)mcp($|[/?#])/.test(resource) ) { return { kind: "dynamic_client_registration", integration: null }; } return { kind: "manual" }; }; interface LoadedOAuthClient { readonly slug: string; readonly authorizationUrl: string; readonly tokenUrl: string; readonly grant: OAuthGrant; readonly clientId: string; /** Resolved literal secret (read from the provider via the stored item id). */ readonly clientSecret: string; readonly resource: string | null; } /** Where an OAuth app's client secret is stored in the default writable * provider — derived solely from the app's (owner, slug) identity. */ const clientSecretItemId = (owner: Owner, slug: OAuthClientSlug): string => `oauth-client:${owner}:${slug}:secret`; const expiresAtFrom = (token: OAuth2TokenResponse): number | null => typeof token.expires_in === "number" ? Date.now() + token.expires_in * 1000 : null; /** Error message surfaced when a redirect-requiring OAuth flow runs on an * executor that was constructed without a `redirectUri`. Previously this path * silently used `http://127.0.0.1/callback`, which providers stored as the * client's callback and then rejected (or worse, accepted, handing tokens to * localhost). Fail loudly so the misconfiguration is caught at the call site. */ const REDIRECT_URI_REQUIRED_MESSAGE = "OAuth redirect flow requires a configured redirectUri, but none was provided " + "to the executor. Pass `redirectUri` to createExecutor (hosts derive it from " + "the web base URL / request origin as `${webBaseUrl}${mountPrefix}/oauth/callback`)."; const canonicalUrlString = (value: string): string => { const url = new URL(value.trim()); url.hash = ""; return url.toString(); }; const isWellKnownOAuthMetadataUrl = (value: string): boolean => { const path = new URL(value.trim()).pathname.toLowerCase(); return ( path.includes("/.well-known/oauth-authorization-server") || path.includes("/.well-known/openid-configuration") || path.includes("/.well-known/oauth-protected-resource") ); }; const validateSupportedEndpoint = ( value: string, label: string, endpointUrlPolicy: OAuthEndpointUrlPolicy | undefined, ): Effect.Effect<void, StorageFailure> => Effect.try({ try: () => assertSupportedOAuthEndpointUrl(value, label, endpointUrlPolicy), catch: (cause) => new StorageError({ message: `Invalid OAuth client endpoint configuration: ${label} must use https: or loopback http:.`, cause, }), }).pipe(Effect.asVoid); const validateClientEndpoints = ( input: CreateOAuthClientInput, endpointUrlPolicy: OAuthEndpointUrlPolicy | undefined, ): Effect.Effect<void, StorageFailure> => Effect.gen(function* () { yield* validateSupportedEndpoint(input.tokenUrl, "token_url", endpointUrlPolicy); if (input.resource != null && input.resource.trim().length > 0) { yield* validateSupportedEndpoint(input.resource, "resource", endpointUrlPolicy); } if (input.grant !== "authorization_code") return; yield* validateSupportedEndpoint( input.authorizationUrl, "authorization_url", endpointUrlPolicy, ); if (isWellKnownOAuthMetadataUrl(input.authorizationUrl)) { return yield* new StorageError({ message: "Invalid OAuth client endpoint configuration: authorization_url must be the OAuth authorization endpoint, not a .well-known metadata URL.", cause: undefined, }); } if (canonicalUrlString(input.authorizationUrl) === canonicalUrlString(input.tokenUrl)) { return yield* new StorageError({ message: "Invalid OAuth client endpoint configuration: authorization_url must not equal token_url.", cause: undefined, }); } }); export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; const fetch = deps.fetch; // EXPLICIT — no localhost default. `null` means this executor has no OAuth // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. const redirectUri = deps.redirectUri; const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy }; const filterAuthorizationCodeScopes = ( client: LoadedOAuthClient, requestedScopes: readonly string[], ): Effect.Effect<readonly string[], never> => Effect.gen(function* () { if (requestedScopes.length === 0) return requestedScopes; const resource = client.resource ? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe( Effect.catch(() => Effect.succeed(null)), Effect.provide(httpClientLayer), ) : null; const issuer = resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin; const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe( Effect.catch(() => Effect.succeed(null)), Effect.provide(httpClientLayer), ); return intersectScopes(requestedScopes, as?.metadata.scopes_supported); }).pipe(Effect.catch(() => Effect.succeed(requestedScopes))); // Caps on server-controlled discovery input — a hostile or buggy server must // not be able to hang `oauth.start` or overflow the authorize URL. const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template const capScopes = (scopes: readonly string[]): readonly string[] => dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES); // Discover the scopes to request when the integration declares none — only // reached for integrations that opt in (MCP-style). The resource's own RFC // 9728 `scopes_supported` is authoritative when present, even when empty (§2 // defines the field; §7.2 cautions against requesting more than it lists). // Only when the resource is SILENT do we read the scopes advertised by the // authorization servers it NAMES (RFC 8414) — we never probe arbitrary URLs. const discoverScopesForResource = ( resource: string | null, ): Effect.Effect<readonly string[], OAuthDiscoveryError> => Effect.gen(function* () { if (resource == null) { return yield* new OAuthDiscoveryError({ message: "Cannot discover OAuth scopes: the client has no resource configured", }); } // `httpClientLayer` flows through `options` so discovery uses the host's // configured client (discovery self-provides from `options.httpClientLayer`). const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy, httpClientLayer }; const protectedResource = yield* discoverProtectedResourceMetadata( resource, discoveryOptions, ); const resourceScopes = protectedResource?.metadata.scopes_supported; if (resourceScopes !== undefined) return capScopes(resourceScopes); // The resource is silent on scopes — read them from the authorization // servers it names, in order. An advertised list is authoritative even // when empty. Any AS we cannot read clean RFC 8414 metadata from — // unreachable, 404, malformed, or issuer-mismatched — contributes nothing // and we move on (mirroring the dynamic-registration discovery path); if // none advertise scopes we request none and let the AS apply its defaults // (RFC 8414 metadata is optional, so its absence is not a failure). The // list is server-controlled, so cap how many of its hosts we probe. for (const issuer of (protectedResource?.metadata.authorization_servers ?? []).slice( 0, MAX_DISCOVERY_AUTH_SERVERS, )) { const authServer = yield* discoverAuthorizationServerMetadata( issuer, discoveryOptions, ).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null))); const scopes = authServer?.metadata.scopes_supported; if (scopes !== undefined) return capScopes(scopes); } return []; }).pipe( // Bound the whole sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS // fetches, each with its own request timeout). 30s is larger than a single // request timeout so it bounds the sequence, not a slow-but-valid request. Effect.timeoutOrElse({ duration: Duration.seconds(30), orElse: () => Effect.fail( new OAuthDiscoveryError({ message: "OAuth scope discovery timed out", cause: "timeout", }), ), }), ); // ----------------------------------------------------------------------- // createClient — write the oauth_client row. // ----------------------------------------------------------------------- const createClient = ( input: CreateOAuthClientInput, ): Effect.Effect<OAuthClientSlug, StorageFailure> => Effect.gen(function* () { yield* validateClientEndpoints(input, deps.endpointUrlPolicy); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => new StorageError({ message: "Cannot write oauth_client for owner without a subject", cause, }), }); const now = new Date(); // Store the secret out-of-band in the default writable provider; the row // keeps only its item id. A public/PKCE client (empty secret) stores null // — there is no plaintext column to fall back to (the schema dropped it). let clientSecretItemIdValue: string | null = null; if (input.clientSecret.length > 0) { const provider = deps.defaultWritableProvider(); if (!provider || !provider.set) { return yield* new StorageError({ message: "No default writable credential provider is registered to store the OAuth client secret.", cause: undefined, }); } clientSecretItemIdValue = clientSecretItemId(input.owner, input.slug); yield* provider.set(ProviderItemId.make(clientSecretItemIdValue), input.clientSecret); } yield* deps.fuma .use("oauth_client.deleteExisting", (db) => looseDb(db).deleteMany("oauth_client", { where: (b: any) => b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), }), )