// ---------------------------------------------------------------------------// 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, Match, Option, Predicate, Schema } from "effect";import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier";import type { Connection } from "./connection";import type { IFumaClient, StorageFailure } from "./fuma-runtime";import { afterCommit, StorageError } from "./fuma-runtime";import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, OAuthState, Owner, ProviderItemId,} from "./ids";import { DEFAULT_SUBJECT_TOKEN_TYPE, OAuthCompleteError, OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, firstPartyOAuthClientAllowsScopes, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, type ConnectResult, type CreateOAuthClientInput, type EnterpriseManagedStartInput, type FirstPartyOAuthClientConfig, type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, type OAuthGrant, type OAuthProbeInput, type OAuthProbeResult, type OAuthService, type OAuthStartInput, type RegisterDynamicClientInput, type SubjectTokenType,} 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 { ENTERPRISE_MANAGED_ROLLOUT_ENABLED, runEnterpriseManagedAuthorization, type EnterpriseManagedConnectionState, type EnterpriseManagedGrant, type EnterpriseManagedMintError, type EnterpriseManagedRollout, type EnterpriseManagedRolloutContext, type EnterpriseManagedRolloutDecision, type EnterpriseManagedRolloutEvent,} from "./oauth-ema";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[]; /** Enterprise-managed authorization wiring, for connections minted through * the ID-JAG grant profile. Persisted on the connection so token renewal can * re-run the exchange without the user. Omitted for every other grant. */ readonly enterpriseManaged?: EnterpriseManagedConnectionState; /** 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;} /** Project an enterprise-managed mint failure onto the connect boundary, * KEEPING the taxonomy structural. `EmaPolicyDenied` is the one verdict a * console must treat differently from every other start failure: it means the * administrator declined, so re-authenticating cannot help and the ordinary * per-server flow must not be offered as a way around it. That decision has to * be readable as a field — a UI cannot branch on a sentence. */const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAuthStartError => { const rendered = (failure: EnterpriseManagedMintError): string => // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: every EMA error declares `message` as a getter over its own typed fields, so this is a projection of a typed failure, not a read off an unknown throwable failure.message; return Match.value(cause).pipe( Match.tag( "EmaPolicyDenied", (denied) => new OAuthStartError({ message: rendered(denied), blockedByAdmin: true, oauthErrorCode: denied.error, }), ), Match.tag( "EmaRedemptionRejected", (rejected) => new OAuthStartError({ message: rendered(rejected), ...(rejected.error === undefined ? {} : { oauthErrorCode: rejected.error }), }), ), Match.tag( "EmaSubjectTokenRejected", "EmaUpstreamUnavailable", (failure) => new OAuthStartError({ message: rendered(failure) }), ), Match.exhaustive, );}; /** 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. * * `discover` carries the integration's own discovery URL (the MCP endpoint) * so scope discovery does not depend on the CLIENT having a persisted RFC * 8707 resource: a user may clear the client's resource (Entra v2 rejects * the parameter, #1789) without losing scope discovery. */export type OAuthScopePolicy = | { readonly kind: "scopes"; readonly scopes: readonly string[] } | { readonly kind: "discover"; readonly discoveryUrl: string }; /** 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", discoveryUrl }`: 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. * `discoveryUrl` is the integration's protected-resource URL (the MCP * endpoint), used when the client persists no resource. */ readonly resolveOAuthScopePolicy: ( integration: IntegrationSlug, template: AuthTemplateSlug, ) => Effect.Effect<OAuthScopePolicy, StorageFailure>; readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; readonly fetch?: typeof globalThis.fetch; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * Host-owned rollout gate for enterprise-managed authorization (see * {@link EnterpriseManagedRollout}). Consulted ONCE per `id_jag` connect, * before discovery, and never again — not after the IdP has ruled, and not on * the credential-refresh path, which follows the state persisted on the * connection instead. * * OMITTED means enterprise-managed authorization is attempted, which is what * every host did before this seam existed. Only a host that actually operates * a flag service supplies one; core takes no dependency on any. */ readonly enterpriseManagedRollout?: EnterpriseManagedRollout; /** * 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; /** Host-operated apps declared at composition time (`first-party:<name>` * slugs). Resolved from config, never from storage: `loadClient` intercepts * the prefix ahead of the DB, `listClients` appends their summaries, and the * client CRUD surface rejects the namespace. Empty/omitted on hosts that * ship no first-party apps. */ readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[];} 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`; /** The item a refresh writes to prove the credential store will ACCEPT a write, * before the grant spends the single-use refresh token. It holds no credential * and never has. * * It has to be its own item. The cheaper-looking probe — rewriting the refresh * token with the value just read — is a read-then-write with no * compare-and-set, and two refreshers of one connection on different instances * lose the newer token to it: A reads R0, B consumes R0 and stores the rotated * R1, then A's probe puts R0 back over R1 and the connection is dead the next * time anything needs it. The in-memory single-flight gate spans one instance * only, and a backing store whose own write path is read-latest-then-write * cannot catch it either. * * The id is the refresh item's id plus a fixed suffix, rather than one * rebuilt from the connection's parts, so it carries the same prefix and * therefore the same embedded owner — the same store partition, the same * encryption context, the same object-name head. A store that would refuse * the refresh token's write refuses this one. Per connection rather than one * per partition, so the only writers that can contend on it are the * concurrent refreshers of a single connection, and they all write the same * constant. */export const storeWritabilityProbeItemIdFor = (refreshItemId: string): string => `${refreshItemId}:store-probe`; /** What the writability probe stores. A constant, because the item exists to * prove a write lands and carries no information of its own. */export const STORE_WRITABILITY_PROBE_VALUE = "writable"; /** 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 === "id_jag" ? 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.Lines 1–500 of 2239. 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, Match, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { connectionIdentifier } from "./connection-name-identifier"; import type { Connection } from "./connection"; import type { IFumaClient, StorageFailure } from "./fuma-runtime"; import { afterCommit, StorageError } from "./fuma-runtime"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, OAuthState, Owner, ProviderItemId, } from "./ids"; import { DEFAULT_SUBJECT_TOKEN_TYPE, OAuthCompleteError, OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, firstPartyOAuthClientAllowsScopes, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, type ConnectResult, type CreateOAuthClientInput, type EnterpriseManagedStartInput, type FirstPartyOAuthClientConfig, type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, type OAuthGrant, type OAuthProbeInput, type OAuthProbeResult, type OAuthService, type OAuthStartInput, type RegisterDynamicClientInput, type SubjectTokenType, } 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 { ENTERPRISE_MANAGED_ROLLOUT_ENABLED, runEnterpriseManagedAuthorization, type EnterpriseManagedConnectionState, type EnterpriseManagedGrant, type EnterpriseManagedMintError, type EnterpriseManagedRollout, type EnterpriseManagedRolloutContext, type EnterpriseManagedRolloutDecision, type EnterpriseManagedRolloutEvent, } from "./oauth-ema"; 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[]; /** Enterprise-managed authorization wiring, for connections minted through * the ID-JAG grant profile. Persisted on the connection so token renewal can * re-run the exchange without the user. Omitted for every other grant. */ readonly enterpriseManaged?: EnterpriseManagedConnectionState; /** 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; } /** Project an enterprise-managed mint failure onto the connect boundary, * KEEPING the taxonomy structural. `EmaPolicyDenied` is the one verdict a * console must treat differently from every other start failure: it means the * administrator declined, so re-authenticating cannot help and the ordinary * per-server flow must not be offered as a way around it. That decision has to * be readable as a field — a UI cannot branch on a sentence. */ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAuthStartError => { const rendered = (failure: EnterpriseManagedMintError): string => // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: every EMA error declares `message` as a getter over its own typed fields, so this is a projection of a typed failure, not a read off an unknown throwable failure.message; return Match.value(cause).pipe( Match.tag( "EmaPolicyDenied", (denied) => new OAuthStartError({ message: rendered(denied), blockedByAdmin: true, oauthErrorCode: denied.error, }), ), Match.tag( "EmaRedemptionRejected", (rejected) => new OAuthStartError({ message: rendered(rejected), ...(rejected.error === undefined ? {} : { oauthErrorCode: rejected.error }), }), ), Match.tag( "EmaSubjectTokenRejected", "EmaUpstreamUnavailable", (failure) => new OAuthStartError({ message: rendered(failure) }), ), Match.exhaustive, ); }; /** 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. * * `discover` carries the integration's own discovery URL (the MCP endpoint) * so scope discovery does not depend on the CLIENT having a persisted RFC * 8707 resource: a user may clear the client's resource (Entra v2 rejects * the parameter, #1789) without losing scope discovery. */ export type OAuthScopePolicy = | { readonly kind: "scopes"; readonly scopes: readonly string[] } | { readonly kind: "discover"; readonly discoveryUrl: string }; /** 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", discoveryUrl }`: 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. * `discoveryUrl` is the integration's protected-resource URL (the MCP * endpoint), used when the client persists no resource. */ readonly resolveOAuthScopePolicy: ( integration: IntegrationSlug, template: AuthTemplateSlug, ) => Effect.Effect<OAuthScopePolicy, StorageFailure>; readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; readonly fetch?: typeof globalThis.fetch; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * Host-owned rollout gate for enterprise-managed authorization (see * {@link EnterpriseManagedRollout}). Consulted ONCE per `id_jag` connect, * before discovery, and never again — not after the IdP has ruled, and not on * the credential-refresh path, which follows the state persisted on the * connection instead. * * OMITTED means enterprise-managed authorization is attempted, which is what * every host did before this seam existed. Only a host that actually operates * a flag service supplies one; core takes no dependency on any. */ readonly enterpriseManagedRollout?: EnterpriseManagedRollout; /** * 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; /** Host-operated apps declared at composition time (`first-party:<name>` * slugs). Resolved from config, never from storage: `loadClient` intercepts * the prefix ahead of the DB, `listClients` appends their summaries, and the * client CRUD surface rejects the namespace. Empty/omitted on hosts that * ship no first-party apps. */ readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[]; } 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`; /** The item a refresh writes to prove the credential store will ACCEPT a write, * before the grant spends the single-use refresh token. It holds no credential * and never has. * * It has to be its own item. The cheaper-looking probe — rewriting the refresh * token with the value just read — is a read-then-write with no * compare-and-set, and two refreshers of one connection on different instances * lose the newer token to it: A reads R0, B consumes R0 and stores the rotated * R1, then A's probe puts R0 back over R1 and the connection is dead the next * time anything needs it. The in-memory single-flight gate spans one instance * only, and a backing store whose own write path is read-latest-then-write * cannot catch it either. * * The id is the refresh item's id plus a fixed suffix, rather than one * rebuilt from the connection's parts, so it carries the same prefix and * therefore the same embedded owner — the same store partition, the same * encryption context, the same object-name head. A store that would refuse * the refresh token's write refuses this one. Per connection rather than one * per partition, so the only writers that can contend on it are the * concurrent refreshers of a single connection, and they all write the same * constant. */ export const storeWritabilityProbeItemIdFor = (refreshItemId: string): string => `${refreshItemId}:store-probe`; /** What the writability probe stores. A constant, because the item exists to * prove a write lands and carries no information of its own. */ export const STORE_WRITABILITY_PROBE_VALUE = "writable"; /** 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 === "id_jag" ? 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.