1521 lines · 67.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,  type OAuthAuthorizationServerMetadata,} 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";import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc"; /** 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;  /** Display label derived from the provider (OIDC id_token claims), as opposed   *  to `identityLabel` which the user chose. Only fills an EMPTY label slot:   *  a re-mint must never clobber a curated label with a derived one. */  readonly derivedIdentityLabel?: 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;  readonly missingOAuthScopes?: readonly string[];  /** 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>;  /** Whether a connection row exists under `(owner, integration, name)`: the   *  raw row, not the policy-filtered list, so `start` can resolve a free   *  name for `newConnection` flows against what is actually stored. */  readonly connectionNameTaken: (ref: {    readonly owner: Owner;    readonly integration: IntegrationSlug;    readonly name: ConnectionName;  }) => Effect.Effect<boolean, 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 OAUTH_SCOPE_ALIASES: Readonly<Record<string, string>> = {  "https://www.googleapis.com/auth/userinfo.email": "email",  "https://www.googleapis.com/auth/userinfo.profile": "profile",}; const informationalOAuthScopes = new Set(["openid", "email", "profile", "offline_access"]); /** Canonicalize a scope for granted-vs-requested comparison. Microsoft's token *  endpoint returns Graph scopes fully qualified *  (`https://graph.microsoft.com/Mail.ReadWrite`) even when the request used *  the short form, so resource-URI prefixes are stripped down to the scope's *  final path segment before comparing. */const canonicalOAuthScope = (scope: string): string => {  const aliased = OAUTH_SCOPE_ALIASES[scope];  if (aliased) return aliased;  if (/^https?:\/\/graph\.microsoft\.(com|us|de)\//i.test(scope)) {    return scope.slice(scope.lastIndexOf("/") + 1);  }  return scope;}; /** `.default` is a request-time meta-scope (Microsoft expands it server-side *  and never echoes it in the granted scope), so it can never be "missing". */const isMetaOAuthScope = (scope: string): boolean => scope.toLowerCase().endsWith("/.default"); const normalizedOAuthScopeSet = (scopes: readonly string[]): ReadonlySet<string> =>  new Set(scopes.map((scope) => canonicalOAuthScope(scope.trim())).filter(Boolean)); export const missingGrantedOAuthScopes = (  requestedScopes: readonly string[],  recordedScope: string | null,): readonly string[] => {  const granted = normalizedOAuthScopeSet(recordedScope?.split(/\s+/).filter(Boolean) ?? []);  const seen = new Set<string>();  const out: string[] = [];  for (const raw of requestedScopes) {    const trimmed = raw.trim();    if (isMetaOAuthScope(trimmed)) continue;    const scope = canonicalOAuthScope(trimmed);    if (scope.length === 0 || informationalOAuthScopes.has(scope) || seen.has(scope)) continue;    seen.add(scope);    if (!granted.has(scope)) out.push(scope);  }  return out;}; 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 canonicalDcrIssuer = (  issuer: string | null | undefined,  registrationEndpoint: string,): string | null => {  const discovered = canonicalIssuerUrl(issuer);  if (discovered !== null) return discovered;  const endpoint = parseUrl(registrationEndpoint);  return endpoint === null ? null : endpoint.origin;}; const issuerOrigin = (issuer: string): string | null => parseUrl(issuer)?.origin ?? null; const issuerIsOriginOnly = (issuer: string): boolean => issuerOrigin(issuer) === issuer; const dcrIssuerMatches = (rowIssuer: string, inputIssuer: string | null): boolean =>  inputIssuer !== null &&  (rowIssuer === inputIssuer ||    (issuerIsOriginOnly(inputIssuer) && issuerOrigin(rowIssuer) === inputIssuer)); const slugifyOAuthKey = (value: string): string =>  value    .toLowerCase()    .replace(/[^a-z0-9]+/g, "-")    .replace(/^-+|-+$/g, ""); const shortStableHash = (value: string): string => {  let hash = 0x811c9dc5;  for (let i = 0; i < value.length; i += 1) {    hash ^= value.charCodeAt(i);    hash = Math.imul(hash, 0x01000193);  }  return (hash >>> 0).toString(36);}; const dcrClientSlug = (  issuer: string | null,  resource: string | null,  fallback: OAuthClientSlug,): OAuthClientSlug => {  if (issuer === null) return fallback;  const issuerHost = hostOfUrl(issuer);  if (issuerHost === null) return fallback;  const base = `dcr-${slugifyOAuthKey(issuerHost) || "authorization-server"}`;  if (resource === null) return OAuthClientSlug.make(base);  const resourceUrl = parseUrl(resource);  const resourceSource =    resourceUrl === null ? resource : `${resourceUrl.host}${resourceUrl.pathname}`;  const resourcePart = slugifyOAuthKey(resourceSource).slice(0, 60) || "resource";  return OAuthClientSlug.make(`${base}-${resourcePart}-${shortStableHash(resource)}`.slice(0, 240));}; /** Dedupe a freshly-minted DCR slug against slugs already held by an owner's *  DCR candidates, appending `-2`, `-3`, … so a new client never collides with *  (and clobbers, via createClient's delete-then-create) an existing one. */const uniqueDcrSlug = (slug: OAuthClientSlug, taken: ReadonlySet<string>): OAuthClientSlug => {  const base = String(slug);  if (!taken.has(base)) return slug;  let suffix = 2;  while (taken.has(`${base}-${suffix}`)) suffix += 1;  return OAuthClientSlug.make(`${base}-${suffix}`);}; const parseOAuthClientOrigin = (row: {  readonly slug?: unknown;  readonly grant?: unknown;  readonly resource?: unknown;  readonly origin_kind?: unknown;  readonly origin_integration?: unknown;}): OAuthClientOrigin => {  // Shared DCR classification (explicit origin_kind OR the legacy MCP-shaped  // heuristic) lives in `oauth-gc` so the runtime and the GC/backfill  // migrations agree exactly on what counts as a DCR row.  if (!isDcrClassifiedRow(row)) {    return {      kind: "manual",      integration:        row.origin_integration == null          ? null          : IntegrationSlug.make(String(row.origin_integration)),    };  }  // An explicit-origin DCR row carries its requesting integration; a  // heuristic-classified legacy row (null origin_kind) has none.  return {    kind: "dynamic_client_registration",    integration:      row.origin_kind === "dynamic_client_registration" && row.origin_integration != null        ? IntegrationSlug.make(String(row.origin_integration))        : null,  };}; 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 oauthMetadataMatchesClient = (  client: Pick<LoadedOAuthClient, "authorizationUrl" | "tokenUrl">,  metadata: OAuthAuthorizationServerMetadata,): boolean =>  canonicalUrlString(metadata.authorization_endpoint) ===    canonicalUrlString(client.authorizationUrl) &&  canonicalUrlString(metadata.token_endpoint) === canonicalUrlString(client.tokenUrl); 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 = (

Lines 1–500 of 1521.