1188 lines · 52.5 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// 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.