431 lines · 16.8 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// OAuth HTTP API — the v2 OAuth surface.//// OAuth is a credential mechanism, not an integration type. A `createClient`// registers an owner-scoped app (its own endpoints + client id/secret); `start`// runs that client's flow to mint a Connection for one integration; `complete`// exchanges the authorization code; `cancel` drops an in-flight session;// `probe` discovers an authorization-server's metadata for the onboarding UI.//// NOTE(v2): `start`/`complete` are STUBBED in the SDK (milestone 2) — the routes// are wired to call them but will fail at runtime until the flow is implemented.// --------------------------------------------------------------------------- import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi";import { Schema } from "effect"; import {  AuthTemplateSlug,  ConnectionAddress,  ConnectionName,  EnterpriseManagedStartInputSchema,  IntegrationSlug,  InternalError,  OAuthClientSlug,  OAuthCompleteError,  OAuthProbeError,  OAuthRegisterDynamicError,  OAuthSessionNotFoundError,  OAuthStartError,  OAuthState,  Owner,  ProviderKey,  WorkIdentityLinkError,  WorkIdentityStatusSchema,} from "@executor-js/sdk/shared"; // ---------------------------------------------------------------------------// Shared connection projection (start "connected" / complete results).// --------------------------------------------------------------------------- const ConnectionResponse = Schema.Struct({  owner: Owner,  name: ConnectionName,  integration: IntegrationSlug,  template: AuthTemplateSlug,  provider: ProviderKey,  address: ConnectionAddress,  identityLabel: Schema.NullOr(Schema.String),  expiresAt: Schema.NullOr(Schema.Number),  // The OAuth app (`oauth_client` slug) that minted this connection — these  // results always come from an OAuth flow, so it is non-null in practice. Just  // a slug, never a secret; kept consistent with the connections-list shape.  oauthClient: Schema.NullOr(OAuthClientSlug),  oauthClientOwner: Schema.NullOr(Owner),  oauthScope: Schema.NullOr(Schema.String),  missingOAuthScopes: Schema.Array(Schema.String),  // True when this connect took the enterprise-managed branch (the ID-JAG  // chain) rather than the interactive one. A `start` against an `id_jag`  // client can still land here as `false` — the profile falls back when the  // server does not advertise it — so the caller reads the outcome, not its  // own intent.  enterpriseManaged: Schema.Boolean,}); // ---------------------------------------------------------------------------// createClient — register an owner-scoped OAuth app.// --------------------------------------------------------------------------- const CreateClientPayload = Schema.Struct({  owner: Owner,  slug: OAuthClientSlug,  authorizationUrl: Schema.String,  tokenUrl: Schema.String,  grant: Schema.Literals(["authorization_code", "client_credentials", "id_jag"]),  clientId: Schema.String,  clientSecret: Schema.String,  resource: Schema.optional(Schema.NullOr(Schema.String)),  /** Integration whose connect dialog registered this manual app. Recorded so   *  the picker matches it to this integration by intent, not root domain. */  originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)),}); const CreateClientResponse = Schema.Struct({  client: OAuthClientSlug,}); // ---------------------------------------------------------------------------// registerDynamic — RFC 7591 Dynamic Client Registration. The server mints the// client id (and possibly a client secret); the user pastes NOTHING. The payload// deliberately carries NO clientId/clientSecret, and the response is the slug// only — the minted secret is never returned over the wire.// --------------------------------------------------------------------------- const RegisterDynamicPayload = Schema.Struct({  owner: Owner,  slug: OAuthClientSlug,  issuer: Schema.optional(Schema.NullOr(Schema.String)),  registrationEndpoint: Schema.String,  authorizationUrl: Schema.String,  tokenUrl: Schema.String,  resource: Schema.optional(Schema.NullOr(Schema.String)),  scopes: Schema.Array(Schema.String),  tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)),  clientName: Schema.optional(Schema.String),  redirectUri: Schema.optional(Schema.NullOr(Schema.String)),  originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)),}); const RegisterDynamicResponse = Schema.Struct({  client: OAuthClientSlug,}); // ---------------------------------------------------------------------------// listClients — metadata-only summaries of the clients visible to the caller// (their org's shared clients + their own user clients). The `clientSecret` is// NEVER part of this projection.// --------------------------------------------------------------------------- const OAuthClientSummaryResponse = Schema.Struct({  owner: Owner,  slug: OAuthClientSlug,  grant: Schema.Literals(["authorization_code", "client_credentials", "id_jag"]),  authorizationUrl: Schema.String,  tokenUrl: Schema.String,  resource: Schema.optional(Schema.NullOr(Schema.String)),  clientId: Schema.String,  origin: Schema.Union([    Schema.Struct({ kind: Schema.Literal("manual") }),    Schema.Struct({      kind: Schema.Literal("dynamic_client_registration"),      integration: Schema.optional(Schema.NullOr(IntegrationSlug)),    }),    /** Host-operated app declared in executor config — every org connects     *  through it; nothing to paste. `integrations` ranks it as the default     *  for those integrations; `allowedScopes` is the host-enforced scope     *  boundary the picker mirrors before offering it. */    Schema.Struct({      kind: Schema.Literal("first_party"),      integrations: Schema.optional(Schema.Array(IntegrationSlug)),      allowedScopes: Schema.optional(Schema.Array(Schema.String)),    }),  ]),}); const ListClientsResponse = Schema.Array(OAuthClientSummaryResponse); // ---------------------------------------------------------------------------// removeClient — permanently delete an owner-scoped OAuth app. The app is keyed// by (owner, slug) — the slug alone is not globally unique — so the slug is a// path param and the owner is in the payload (mirrors the policies/connections// delete shape). Idempotent: removing an already-gone app still returns// `{ removed: true }`. Connections that referenced the slug are NOT cascaded;// they keep their stored value and fail at the next token refresh.// --------------------------------------------------------------------------- const RemoveClientParams = { slug: OAuthClientSlug }; const RemoveClientPayload = Schema.Struct({  owner: Owner,}); const RemoveClientResponse = Schema.Struct({  removed: Schema.Boolean,}); // ---------------------------------------------------------------------------// start — run a client's flow to mint a connection for one integration. The// status discriminates "connected" (inline, e.g. client_credentials) from// "redirect" (user must visit the authorization URL).// --------------------------------------------------------------------------- const StartPayload = Schema.Struct({  client: OAuthClientSlug,  /** The owner of `client` (a Personal connection may use a shared Workspace app). */  clientOwner: Owner,  owner: Owner,  name: ConnectionName,  integration: IntegrationSlug,  template: AuthTemplateSlug,  identityLabel: Schema.optional(Schema.NullOr(Schema.String)),  /** Mint a NEW connection: a taken `name` resolves to the next free suffixed   *  name server-side instead of re-minting the existing row. */  newConnection: Schema.optional(Schema.Boolean),  redirectUri: Schema.optional(Schema.NullOr(Schema.String)),  /** Enterprise-managed authorization inputs (MCP EMA profile). Required when   *  the named client's grant is `id_jag`, ignored otherwise: the client's own   *  id/secret authenticate at the MCP server's authorization server, while   *  these name the SECOND registration at the enterprise identity provider and   *  carry the identity assertion the user already holds from single sign-on. */  enterprise: Schema.optional(EnterpriseManagedStartInputSchema),}); const StartResponse = Schema.Union([  Schema.Struct({    status: Schema.Literal("connected"),    connection: ConnectionResponse,  }),  Schema.Struct({    status: Schema.Literal("redirect"),    authorizationUrl: Schema.String,    state: OAuthState,  }),]); // ---------------------------------------------------------------------------// complete — exchange the authorization code, mint the connection.// --------------------------------------------------------------------------- const CompletePayload = Schema.Struct({  state: OAuthState,  code: Schema.String,  /** Regional host echoed back by the authorization server (Datadog's   *  `domain`/`site`); forwarded so the code is redeemed at the org's region. */  callbackDomain: Schema.optional(Schema.NullOr(Schema.String)),}); // ---------------------------------------------------------------------------// cancel — drop an in-flight session without exchanging.// --------------------------------------------------------------------------- const CancelPayload = Schema.Struct({  state: OAuthState,}); const CancelResponse = Schema.Struct({  cancelled: Schema.Boolean,}); // ---------------------------------------------------------------------------// probe — discover an authorization-server's metadata.// --------------------------------------------------------------------------- const ProbePayload = Schema.Struct({  url: Schema.String,}); const ProbeResponse = Schema.Struct({  issuer: Schema.optional(Schema.NullOr(Schema.String)),  authorizationUrl: Schema.String,  tokenUrl: Schema.String,  resource: Schema.optional(Schema.NullOr(Schema.String)),  scopesSupported: Schema.optional(Schema.Array(Schema.String)),  registrationEndpoint: Schema.optional(Schema.NullOr(Schema.String)),  tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)),  clientIdMetadataDocumentSupported: Schema.optional(Schema.Boolean),}); // ---------------------------------------------------------------------------// callback — GET with `state` + `code` (or `error`) query params. Renders the// popup HTML directly; the popup script posts the completion result back to the// opener via `postMessage` / `BroadcastChannel`.// --------------------------------------------------------------------------- const CallbackUrlParams = Schema.Struct({  state: Schema.String,  code: Schema.optional(Schema.String),  error: Schema.optional(Schema.String),  error_description: Schema.optional(Schema.String),  // Non-standard region hints (Datadog: `domain` is a bare host, `site` a full  // origin). Captured so the token exchange can target the org's region.  domain: Schema.optional(Schema.String),  site: Schema.optional(Schema.String),}); const HtmlResponse = Schema.String.pipe(HttpApiSchema.asText()); // ---------------------------------------------------------------------------// Work identity — acquiring the enterprise assertion an EMA connect presents.//// A user links their enterprise identity ONCE per (owner, IdP app); every// enterprise-managed connect afterwards omits `enterprise.subjectToken` and the// server resolves the held identity. The console's loop is:////   GET  /oauth/work-identity/status  → `unlinked` / `linked` / `needs_relink`//   POST /oauth/work-identity/start   → open `authorizationUrl` in the popup//   (the popup lands on the SHARED /oauth/callback and posts back a//    `{ workIdentity: <status> }` payload; `complete` below is the direct entry//    point for callers that catch the code themselves)//   POST /oauth/work-identity/complete//   DELETE /oauth/work-identity       → forget it//// The three routing fields (`owner`, `idpClient`, `idpClientOwner`) are the same// on every one of them: the integration catalog projects `idpClient` /// `idpClientOwner` on the server's oauth auth method, and `owner` is the owner// the connection will be made under.// --------------------------------------------------------------------------- const WorkIdentityRefFields = {  /** The owner the identity is held under — the same owner the   *  enterprise-managed CONNECTIONS backed by it are made under. */  owner: Owner,  /** The registered OAuth app standing for the enterprise identity provider, as   *  the integration catalog's `enterpriseIdentityProvider` names it. */  idpClient: OAuthClientSlug,  idpClientOwner: Owner,} as const; const StartWorkIdentityLinkPayload = Schema.Struct({  ...WorkIdentityRefFields,  /** Replace the default `openid offline_access` request outright. Send only for   *  an identity provider that needs different scope names — the defaults are   *  what make the link return account claims and a durable refresh token. */  scopes: Schema.optional(Schema.Array(Schema.String)),  redirectUri: Schema.optional(Schema.NullOr(Schema.String)),}); const StartWorkIdentityLinkResponse = Schema.Struct({  authorizationUrl: Schema.String,  state: OAuthState,}); const CompleteWorkIdentityLinkPayload = Schema.Struct({  state: OAuthState,  code: Schema.String,}); const WorkIdentityStatusUrlParams = Schema.Struct(WorkIdentityRefFields); const UnlinkWorkIdentityPayload = Schema.Struct(WorkIdentityRefFields); const UnlinkWorkIdentityResponse = Schema.Struct({  unlinked: Schema.Boolean,}); // ---------------------------------------------------------------------------// Error schemas with HTTP status annotations// --------------------------------------------------------------------------- const OAuthStart = OAuthStartError.annotate({ httpApiStatus: 400 });const OAuthComplete = OAuthCompleteError.annotate({ httpApiStatus: 400 });const OAuthProbe = OAuthProbeError.annotate({ httpApiStatus: 400 });const OAuthRegisterDynamic = OAuthRegisterDynamicError.annotate({ httpApiStatus: 400 });const OAuthSessionNotFound = OAuthSessionNotFoundError.annotate({ httpApiStatus: 404 });const WorkIdentityLink = WorkIdentityLinkError.annotate({ httpApiStatus: 400 }); // ---------------------------------------------------------------------------// Group// --------------------------------------------------------------------------- export const OAuthApi = HttpApiGroup.make("oauth")  .add(    HttpApiEndpoint.post("createClient", "/oauth/clients", {      payload: CreateClientPayload,      success: CreateClientResponse,      error: InternalError,    }),  )  .add(    HttpApiEndpoint.post("registerDynamic", "/oauth/clients/register-dynamic", {      payload: RegisterDynamicPayload,      success: RegisterDynamicResponse,      error: [InternalError, OAuthRegisterDynamic],    }),  )  .add(    HttpApiEndpoint.get("listClients", "/oauth/clients", {      success: ListClientsResponse,      error: InternalError,    }),  )  .add(    HttpApiEndpoint.delete("removeClient", "/oauth/clients/:slug", {      params: RemoveClientParams,      payload: RemoveClientPayload,      success: RemoveClientResponse,      error: InternalError,    }),  )  .add(    HttpApiEndpoint.post("start", "/oauth/start", {      payload: StartPayload,      success: StartResponse,      error: [InternalError, OAuthStart],    }),  )  .add(    HttpApiEndpoint.post("complete", "/oauth/complete", {      payload: CompletePayload,      success: ConnectionResponse,      error: [InternalError, OAuthComplete, OAuthSessionNotFound],    }),  )  .add(    HttpApiEndpoint.post("cancel", "/oauth/cancel", {      payload: CancelPayload,      success: CancelResponse,      error: InternalError,    }),  )  .add(    HttpApiEndpoint.post("probe", "/oauth/probe", {      payload: ProbePayload,      success: ProbeResponse,      error: [InternalError, OAuthProbe],    }),  )  .add(    HttpApiEndpoint.post("startWorkIdentityLink", "/oauth/work-identity/start", {      payload: StartWorkIdentityLinkPayload,      success: StartWorkIdentityLinkResponse,      error: [InternalError, WorkIdentityLink],    }),  )  .add(    HttpApiEndpoint.post("completeWorkIdentityLink", "/oauth/work-identity/complete", {      payload: CompleteWorkIdentityLinkPayload,      success: WorkIdentityStatusSchema,      error: [InternalError, WorkIdentityLink, OAuthSessionNotFound],    }),  )  .add(    HttpApiEndpoint.get("workIdentityStatus", "/oauth/work-identity/status", {      query: WorkIdentityStatusUrlParams,      success: WorkIdentityStatusSchema,      error: InternalError,    }),  )  .add(    HttpApiEndpoint.delete("unlinkWorkIdentity", "/oauth/work-identity", {      payload: UnlinkWorkIdentityPayload,      success: UnlinkWorkIdentityResponse,      error: InternalError,    }),  )  .add(    HttpApiEndpoint.get("callback", "/oauth/callback", {      query: CallbackUrlParams,      success: HtmlResponse,      error: [InternalError, OAuthComplete, OAuthSessionNotFound],    }),  );