@opengeni/api-router 0.30.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-grant-rls.d.ts +3 -0
- package/dist/admission-trace.d.ts +8 -0
- package/dist/api-websocket.d.ts +18 -0
- package/dist/app.d.ts +50 -2
- package/dist/app.js +12 -1
- package/dist/artifact-catalog-cursor.d.ts +27 -0
- package/dist/auth/managed-auth-attempt-context.d.ts +10 -0
- package/dist/auth/managed-auth-rate-limits.d.ts +167 -0
- package/dist/auth/managed-auth-session-adapter.d.ts +6 -0
- package/dist/auth/managed-auth.d.ts +102 -3
- package/dist/auth/managed-email.d.ts +29 -0
- package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
- package/dist/auth/new-signups-gate.d.ts +23 -0
- package/dist/auth/organization-user-setup.d.ts +80 -0
- package/dist/auth/signup-funnel-metrics.d.ts +44 -0
- package/dist/browser-controller-authority.d.ts +1 -61
- package/dist/browser-ephemeral.d.ts +5 -0
- package/dist/browser-state-authority.d.ts +1 -27
- package/dist/chunk-6TZ5LG3O.js +123948 -0
- package/dist/chunk-6TZ5LG3O.js.map +1 -0
- package/dist/chunk-OGQDXVXY.js +18 -0
- package/dist/chunk-OGQDXVXY.js.map +1 -0
- package/dist/claude-subscription-account-conflicts.d.ts +1 -0
- package/dist/claude-subscription-account-usage.d.ts +39 -0
- package/dist/claude-subscription-oauth.d.ts +25 -0
- package/dist/claude-subscription-profile.d.ts +9 -0
- package/dist/claude-subscription-usage.d.ts +44 -0
- package/dist/claude-workspace-connection.d.ts +15 -0
- package/dist/codemode.d.ts +32 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-ownership.d.ts +54 -0
- package/dist/controller-data-plane.d.ts +32 -0
- package/dist/editable-artifact-office-import.d.ts +7 -4
- package/dist/editable-artifact-source-session.d.ts +3 -0
- package/dist/editable-artifact-websocket.d.ts +10 -18
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/fatal-process-boundary.d.ts +25 -0
- package/dist/github-access.d.ts +31 -2
- package/dist/http/acting-person.d.ts +24 -0
- package/dist/http/api-contract-compat.d.ts +19 -0
- package/dist/http/api-error.d.ts +27 -1
- package/dist/http/cors.d.ts +17 -0
- package/dist/http/deprecation.d.ts +42 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/keyed-admission.d.ts +12 -0
- package/dist/http/local-browser-boundary.d.ts +72 -0
- package/dist/http/metrics-listener.d.ts +22 -0
- package/dist/http/path-identifier.d.ts +17 -0
- package/dist/http/registered-route-label.d.ts +16 -0
- package/dist/http/request-body.d.ts +27 -0
- package/dist/http/request-source.d.ts +52 -0
- package/dist/http/sse.d.ts +17 -6
- package/dist/http/unmatched-route.d.ts +18 -0
- package/dist/http/user-content.d.ts +36 -0
- package/dist/http/websocket-upgrade-dispatch.d.ts +18 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +1129 -166
- package/dist/index.js.map +1 -1
- package/dist/integration-endpoint-test.d.ts +20 -0
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/atlassian.d.ts +29 -3
- package/dist/integrations/connect-authority.d.ts +1 -0
- package/dist/integrations/connect-callback-return.d.ts +6 -0
- package/dist/integrations/fiken.d.ts +16 -2
- package/dist/integrations/github-app-connect.d.ts +47 -0
- package/dist/integrations/github-installation-proof.d.ts +3 -0
- package/dist/integrations/github-lens-connect.d.ts +116 -0
- package/dist/integrations/github-skill-source.d.ts +1 -5
- package/dist/integrations/google-drive.d.ts +25 -0
- package/dist/integrations/integration-commit-authority.d.ts +13 -0
- package/dist/integrations/marketplace-plugin.d.ts +2691 -0
- package/dist/integrations/oauth-client.d.ts +178 -18
- package/dist/integrations/oauth-profiles.d.ts +222 -0
- package/dist/integrations/oauth-return-path.d.ts +1 -0
- package/dist/integrations/personal-github-repositories.d.ts +84 -0
- package/dist/integrations/personal-github.d.ts +42 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +19 -1
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +367 -12
- package/dist/integrations/slack-file-upload-flow.d.ts +62 -0
- package/dist/integrations/slack-install.d.ts +20 -0
- package/dist/integrations/slack-interactions.d.ts +113 -10
- package/dist/integrations/slack-mrkdwn.d.ts +41 -0
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/slack-session-defaults.d.ts +58 -0
- package/dist/integrations/slack-task-file-upload.d.ts +36 -0
- package/dist/integrations/social-oauth.d.ts +7 -0
- package/dist/interaction-agent-access.d.ts +15 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/interaction-metrics.d.ts +2 -0
- package/dist/mcp/action-catalog-types.d.ts +9 -0
- package/dist/mcp/action-catalog.gen.d.ts +2 -0
- package/dist/mcp/capability-account-readiness.d.ts +7 -0
- package/dist/mcp/child-read-evidence.d.ts +9 -0
- package/dist/mcp/command-read.d.ts +24 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/contract-input.d.ts +11 -0
- package/dist/mcp/documents.d.ts +7 -5
- package/dist/mcp/files.d.ts +2 -2
- package/dist/mcp/first-party-tool-permissions.d.ts +650 -0
- package/dist/mcp/knowledge-entries.d.ts +5 -0
- package/dist/mcp/orchestration-failure-diagnostic.d.ts +43 -0
- package/dist/mcp/receipts.d.ts +9 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-input.d.ts +1189 -0
- package/dist/mcp/scheduled-task-view.d.ts +22 -4
- package/dist/mcp/server.d.ts +255 -3
- package/dist/mcp/session-event-view.d.ts +94 -0
- package/dist/mcp/session-view.d.ts +66 -2
- package/dist/mcp/session-wait.d.ts +174 -0
- package/dist/mcp-oauth.d.ts +52 -0
- package/dist/model-catalog.d.ts +11 -31
- package/dist/observability.d.ts +3 -1
- package/dist/organization-mcp.d.ts +52 -0
- package/dist/organization-recovery-notifications.d.ts +41 -0
- package/dist/routes/analytics-consent.d.ts +26 -0
- package/dist/routes/api-integrations.d.ts +25 -0
- package/dist/routes/api-keys.d.ts +17 -0
- package/dist/routes/artifact-catalog.d.ts +6 -0
- package/dist/routes/automations.d.ts +19 -0
- package/dist/routes/billing.d.ts +49 -0
- package/dist/routes/browser-sessions.d.ts +71 -3
- package/dist/routes/claude-subscription-accounts.d.ts +3 -0
- package/dist/routes/claude-subscription-oauth.d.ts +3 -0
- package/dist/routes/client-errors.d.ts +146 -0
- package/dist/routes/codex.d.ts +76 -2
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/computer-sessions.d.ts +14 -0
- package/dist/routes/connect.d.ts +5 -0
- package/dist/routes/editable-artifacts.d.ts +2 -0
- package/dist/routes/external-identity-links.d.ts +6 -0
- package/dist/routes/feedback.d.ts +3 -0
- package/dist/routes/files.d.ts +4 -0
- package/dist/routes/github-browser-pages.d.ts +12 -0
- package/dist/routes/github.d.ts +2 -0
- package/dist/routes/insights-response-cache.d.ts +10 -0
- package/dist/routes/insights-usage.d.ts +12 -0
- package/dist/routes/insights.d.ts +30 -1
- package/dist/routes/knowledge.d.ts +3 -0
- package/dist/routes/managed-auth-session-sets.d.ts +26 -0
- package/dist/routes/managed-onboarding.d.ts +29 -0
- package/dist/routes/managed-sign-in-methods.d.ts +6 -0
- package/dist/routes/model-connection-access.d.ts +3 -0
- package/dist/routes/organization-integration-policy.d.ts +3 -0
- package/dist/routes/organization-integrations.d.ts +6 -0
- package/dist/routes/organization-mcp-connections.d.ts +3 -0
- package/dist/routes/organization-model-providers.d.ts +3 -0
- package/dist/routes/organization-recovery.d.ts +19 -0
- package/dist/routes/organization-service-accounts.d.ts +3 -0
- package/dist/routes/organization-sessions.d.ts +26 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/session-artifact-associations.d.ts +4 -0
- package/dist/routes/session-history-imports.d.ts +9 -0
- package/dist/routes/sessions.d.ts +44 -6
- package/dist/routes/skill-content.d.ts +4 -0
- package/dist/routes/skill-install-authority.d.ts +6 -0
- package/dist/routes/subscription-account-pools.d.ts +34 -0
- package/dist/routes/subscription-pool-access.d.ts +16 -0
- package/dist/routes/supergrok.d.ts +6 -1
- package/dist/routes/usage-allowances.d.ts +12 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-artifacts.d.ts +2 -1
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-integrations.d.ts +35 -0
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspace-model-providers.d.ts +4 -0
- package/dist/routes/workspaces.d.ts +29 -0
- package/dist/sandbox/auth-callout.d.ts +8 -0
- package/dist/sandbox/channel-a.d.ts +19 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +9 -1
- package/dist/sandbox/machines.d.ts +5 -2
- package/dist/sandbox/metrics-ingestion.d.ts +28 -20
- package/dist/sandbox/rematerialize.d.ts +2 -0
- package/dist/sandbox/session-attach-variable-sets.d.ts +30 -0
- package/dist/sandbox/viewer.d.ts +26 -1
- package/dist/sandbox-file-artifacts.d.ts +50 -0
- package/dist/scheduled-task-deletion.d.ts +13 -0
- package/dist/session-create-observability.d.ts +5 -0
- package/dist/session-event-view-MPS2OZD3.js +292 -0
- package/dist/session-event-view-MPS2OZD3.js.map +1 -0
- package/dist/site-session-origin.d.ts +13 -0
- package/dist/site-uploads.d.ts +41 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/dist/temporal-schedule-sync.d.ts +26 -0
- package/dist/transcription/providers/xai-subscription.d.ts +1 -0
- package/dist/transcription/service.d.ts +2 -1
- package/dist/work-discovery-observability.d.ts +33 -0
- package/dist/workspace-artifact-content.d.ts +28 -0
- package/dist/workspace-artifact-provenance.d.ts +7 -0
- package/dist/workspace-delete-observability.d.ts +10 -0
- package/dist/workspace-deletion.d.ts +6 -0
- package/dist/workspace-state-projection.d.ts +3 -5
- package/dist/workspace-tool-gateway-observability.d.ts +17 -0
- package/dist/workspace-tool-gateway.d.ts +123 -0
- package/dist/workspace-webhook-dispatch.d.ts +26 -0
- package/package.json +23 -19
- package/src/access-grant-rls.ts +75 -0
- package/src/admission-trace.ts +14 -0
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +1974 -256
- package/src/artifact-catalog-cursor.ts +60 -0
- package/src/auth/managed-auth-attempt-context.ts +70 -0
- package/src/auth/managed-auth-rate-limits.ts +252 -0
- package/src/auth/managed-auth-session-adapter.ts +222 -0
- package/src/auth/managed-auth.ts +536 -60
- package/src/auth/managed-email.ts +174 -0
- package/src/auth/managed-sign-in-notifications.ts +81 -0
- package/src/auth/new-signups-gate.ts +49 -0
- package/src/auth/organization-user-setup.ts +307 -0
- package/src/auth/signup-funnel-metrics.ts +164 -0
- package/src/browser-controller-authority.ts +1 -186
- package/src/browser-ephemeral.ts +39 -0
- package/src/browser-state-authority.ts +1 -236
- package/src/claude-subscription-account-conflicts.ts +12 -0
- package/src/claude-subscription-account-usage.ts +48 -0
- package/src/claude-subscription-oauth.ts +362 -0
- package/src/claude-subscription-profile.ts +73 -0
- package/src/claude-subscription-usage.ts +97 -0
- package/src/claude-workspace-connection.ts +82 -0
- package/src/codemode.ts +194 -30
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-ownership.ts +199 -0
- package/src/controller-data-plane.ts +81 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +77 -9
- package/src/editable-artifact-production.ts +23 -7
- package/src/editable-artifact-source-session.ts +123 -0
- package/src/editable-artifact-websocket.ts +27 -21
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/fatal-process-boundary.ts +246 -0
- package/src/gateway-realtime.ts +1 -1
- package/src/github-access.ts +147 -5
- package/src/http/acting-person.ts +71 -0
- package/src/http/api-contract-compat.ts +49 -0
- package/src/http/api-error.ts +115 -1
- package/src/http/auth.ts +48 -0
- package/src/http/cors.ts +67 -0
- package/src/http/deprecation.ts +118 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/keyed-admission.ts +29 -0
- package/src/http/local-browser-boundary.ts +343 -0
- package/src/http/metrics-listener.ts +57 -0
- package/src/http/path-identifier.ts +54 -0
- package/src/http/registered-route-label.ts +34 -0
- package/src/http/request-body.ts +127 -0
- package/src/http/request-source.ts +178 -0
- package/src/http/sse.ts +486 -132
- package/src/http/unmatched-route.ts +60 -0
- package/src/http/user-content.ts +158 -0
- package/src/http/websocket-upgrade-dispatch.ts +29 -0
- package/src/index.ts +157 -52
- package/src/integration-endpoint-test.ts +291 -0
- package/src/integrations/api-integrations.ts +98 -11
- package/src/integrations/atlassian.ts +474 -136
- package/src/integrations/connect-authority.ts +2 -0
- package/src/integrations/connect-callback-return.ts +86 -0
- package/src/integrations/fiken.ts +354 -64
- package/src/integrations/github-app-connect.ts +435 -0
- package/src/integrations/github-installation-proof.ts +60 -0
- package/src/integrations/github-lens-connect.ts +76 -0
- package/src/integrations/github-skill-source.ts +2 -142
- package/src/integrations/google-drive.ts +820 -222
- package/src/integrations/integration-commit-authority.ts +69 -0
- package/src/integrations/marketplace-plugin.ts +48 -0
- package/src/integrations/oauth-client.ts +1356 -459
- package/src/integrations/oauth-profiles.ts +537 -0
- package/src/integrations/oauth-return-path.ts +18 -0
- package/src/integrations/personal-github-repositories.ts +674 -0
- package/src/integrations/personal-github.ts +825 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +250 -22
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +963 -239
- package/src/integrations/slack-file-upload-flow.ts +147 -0
- package/src/integrations/slack-install.ts +83 -0
- package/src/integrations/slack-interactions.ts +3170 -474
- package/src/integrations/slack-mrkdwn.ts +193 -0
- package/src/integrations/slack-routing.ts +337 -0
- package/src/integrations/slack-session-defaults.ts +224 -0
- package/src/integrations/slack-task-file-upload.ts +323 -0
- package/src/integrations/social-oauth.ts +213 -15
- package/src/interaction-agent-access.ts +48 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/interaction-metrics.ts +30 -0
- package/src/mcp/action-catalog-types.ts +9 -0
- package/src/mcp/action-catalog.gen.ts +6696 -0
- package/src/mcp/capability-account-readiness.ts +17 -0
- package/src/mcp/child-read-evidence.ts +19 -0
- package/src/mcp/command-read.ts +109 -0
- package/src/mcp/company-profile-agent-admin.ts +198 -0
- package/src/mcp/contract-input.ts +141 -0
- package/src/mcp/documents.ts +65 -377
- package/src/mcp/editable-artifacts.ts +12 -6
- package/src/mcp/files.ts +80 -38
- package/src/mcp/first-party-tool-permissions.ts +305 -0
- package/src/mcp/knowledge-entries.ts +322 -0
- package/src/mcp/orchestration-failure-diagnostic.ts +43 -0
- package/src/mcp/receipts.ts +34 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-input.ts +76 -0
- package/src/mcp/scheduled-task-view.ts +44 -3
- package/src/mcp/server.ts +3030 -1267
- package/src/mcp/session-event-view.ts +383 -0
- package/src/mcp/session-view.ts +84 -3
- package/src/mcp/session-wait.ts +665 -0
- package/src/mcp-oauth.ts +1123 -0
- package/src/model-catalog.ts +91 -333
- package/src/observability.ts +15 -1
- package/src/organization-mcp.ts +397 -0
- package/src/organization-recovery-notifications.ts +103 -0
- package/src/routes/analytics-consent.ts +123 -0
- package/src/routes/api-integrations.ts +364 -74
- package/src/routes/api-keys.ts +336 -10
- package/src/routes/artifact-catalog.ts +279 -0
- package/src/routes/automations.ts +768 -0
- package/src/routes/billing.ts +461 -46
- package/src/routes/browser-sessions.ts +839 -131
- package/src/routes/canonical-human-identities.ts +90 -14
- package/src/routes/capabilities.ts +107 -6
- package/src/routes/channels.ts +20 -3
- package/src/routes/claude-subscription-accounts.ts +334 -0
- package/src/routes/claude-subscription-oauth.ts +142 -0
- package/src/routes/client-errors.ts +362 -0
- package/src/routes/codex.ts +833 -116
- package/src/routes/company-brain.ts +322 -0
- package/src/routes/company-profile.ts +65 -1
- package/src/routes/computer-sessions.ts +557 -106
- package/src/routes/connect.ts +1938 -0
- package/src/routes/connections.ts +1040 -349
- package/src/routes/documents.ts +488 -287
- package/src/routes/editable-artifacts.ts +79 -9
- package/src/routes/enrollments.ts +135 -39
- package/src/routes/environments.ts +154 -58
- package/src/routes/external-identity-links.ts +201 -0
- package/src/routes/feedback.ts +113 -0
- package/src/routes/files.ts +337 -39
- package/src/routes/github-browser-pages.ts +162 -0
- package/src/routes/github.ts +446 -144
- package/src/routes/insights-response-cache.ts +83 -0
- package/src/routes/insights-usage.ts +345 -0
- package/src/routes/insights.ts +153 -19
- package/src/routes/install.ts +133 -38
- package/src/routes/integration-facets.ts +107 -33
- package/src/routes/interaction-resources.ts +30 -3
- package/src/routes/knowledge.ts +488 -0
- package/src/routes/machines.ts +313 -15
- package/src/routes/managed-auth-session-sets.ts +1218 -0
- package/src/routes/managed-onboarding.ts +337 -0
- package/src/routes/managed-sign-in-methods.ts +433 -0
- package/src/routes/memory-slack-publications.ts +3 -2
- package/src/routes/model-connection-access.ts +208 -0
- package/src/routes/organization-integration-policy.ts +116 -0
- package/src/routes/organization-integrations.ts +326 -0
- package/src/routes/organization-mcp-connections.ts +178 -0
- package/src/routes/organization-memberships.ts +1019 -27
- package/src/routes/organization-model-providers.ts +293 -0
- package/src/routes/organization-recovery.ts +325 -0
- package/src/routes/organization-service-accounts.ts +128 -0
- package/src/routes/organization-sessions.ts +350 -0
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +370 -0
- package/src/routes/plugins.ts +251 -22
- package/src/routes/pr-review-github.ts +881 -0
- package/src/routes/pr-review.ts +622 -0
- package/src/routes/preference-registry.ts +12 -93
- package/src/routes/rigs.ts +167 -50
- package/src/routes/scheduled-tasks.ts +346 -41
- package/src/routes/session-artifact-associations.ts +87 -0
- package/src/routes/session-history-imports.ts +280 -0
- package/src/routes/sessions.ts +2306 -264
- package/src/routes/skill-content.ts +297 -0
- package/src/routes/skill-install-authority.ts +64 -0
- package/src/routes/skills.ts +62 -9
- package/src/routes/social.ts +46 -18
- package/src/routes/subscription-account-pools.ts +436 -0
- package/src/routes/subscription-pool-access.ts +126 -0
- package/src/routes/supergrok.ts +219 -303
- package/src/routes/transcription-recordings.ts +38 -3
- package/src/routes/transcriptions.ts +4 -0
- package/src/routes/usage-allowances.ts +378 -0
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/video-generation.ts +14 -10
- package/src/routes/workspace-artifacts.ts +275 -69
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-integrations.ts +576 -0
- package/src/routes/workspace-learning.ts +159 -0
- package/src/routes/workspace-model-providers.ts +148 -0
- package/src/routes/workspace-state.ts +38 -25
- package/src/routes/workspaces.ts +1004 -127
- package/src/sandbox/auth-callout.ts +96 -19
- package/src/sandbox/channel-a.ts +323 -24
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +81 -6
- package/src/sandbox/machines.ts +218 -76
- package/src/sandbox/metrics-ingestion.ts +411 -62
- package/src/sandbox/rematerialize.ts +121 -19
- package/src/sandbox/session-attach-variable-sets.ts +131 -0
- package/src/sandbox/viewer.ts +280 -25
- package/src/sandbox-file-artifacts.ts +413 -0
- package/src/scheduled-task-deletion.ts +138 -0
- package/src/session-create-observability.ts +46 -0
- package/src/session-realtime-context.ts +6 -3
- package/src/site-session-origin.ts +48 -0
- package/src/site-uploads.ts +224 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/src/temporal-schedule-sync.ts +74 -0
- package/src/transcription/providers/codex-subscription.ts +2 -0
- package/src/transcription/providers/openai.ts +1 -0
- package/src/transcription/providers/xai-subscription.ts +49 -3
- package/src/transcription/segmenter.ts +8 -2
- package/src/transcription/service.ts +43 -6
- package/src/work-discovery-observability.ts +121 -0
- package/src/workspace-artifact-content.ts +144 -0
- package/src/workspace-artifact-provenance.ts +104 -0
- package/src/workspace-delete-observability.ts +75 -0
- package/src/workspace-deletion.ts +73 -0
- package/src/workspace-state-projection.ts +18 -148
- package/src/workspace-tool-gateway-observability.ts +100 -0
- package/src/workspace-tool-gateway.ts +870 -0
- package/src/workspace-webhook-dispatch.ts +190 -0
- package/src/xai-subscription-auth.ts +9 -2
- package/dist/chunk-2PQMSRCB.js +0 -49759
- package/dist/chunk-2PQMSRCB.js.map +0 -1
- package/dist/routes/packs.d.ts +0 -3
- package/src/routes/packs.ts +0 -729
|
@@ -1,60 +1,154 @@
|
|
|
1
|
+
import { safeReturnPath } from "./oauth-return-path";
|
|
2
|
+
import { assertOrganizationIntegrationAllowed, stableJson } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
withOrganizationIntegrationAcquisition,
|
|
5
|
+
withOrganizationIntegrationPolicyFence,
|
|
6
|
+
} from "@opengeni/db/organization-integration-policy";
|
|
1
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
8
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
9
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
4
10
|
import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
|
|
5
11
|
import {
|
|
6
|
-
OPENGENI_PERSONAL_SLACK_MCP_URL,
|
|
7
12
|
OAuthStartResponse,
|
|
8
13
|
selectCanonicalPersonalSlackConnection,
|
|
9
14
|
type ConnectionOwnership,
|
|
10
15
|
type OAuthStartRequest,
|
|
11
16
|
} from "@opengeni/contracts";
|
|
12
|
-
import {
|
|
17
|
+
import { requireEnvironmentEncryption, integrationKeyForConnectProvider } from "@opengeni/core";
|
|
18
|
+
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
13
19
|
import type { Observability } from "@opengeni/observability";
|
|
14
20
|
import {
|
|
15
21
|
consumeIntegrationOAuthStateNonce,
|
|
22
|
+
claimConnectOperation,
|
|
23
|
+
finishConnectOperation,
|
|
24
|
+
getConnectAttempt,
|
|
16
25
|
createConnection,
|
|
17
26
|
decryptEnvironmentValue,
|
|
18
27
|
encryptEnvironmentValue,
|
|
19
28
|
getConnectionMetadata,
|
|
20
|
-
|
|
29
|
+
getGlobalCatalogOAuthProfile,
|
|
21
30
|
listConnectionsMetadata,
|
|
22
31
|
loadIntegrationOAuthClient,
|
|
32
|
+
loadIntegrationOAuthPendingState,
|
|
23
33
|
normalizeBearerScheme,
|
|
24
34
|
replaceIntegrationOAuthClientIfCurrent,
|
|
25
35
|
storeIntegrationOAuthClient,
|
|
36
|
+
storeIntegrationOAuthPendingState,
|
|
26
37
|
updateConnection,
|
|
27
38
|
withDatabaseStatementTimeout,
|
|
28
39
|
type Database,
|
|
29
40
|
} from "@opengeni/db";
|
|
30
|
-
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
41
|
+
import { createSignedState, inspectSignedState, readSignedState } from "@opengeni/github";
|
|
31
42
|
import {
|
|
32
43
|
DestinationPolicyError,
|
|
44
|
+
McpOAuthDiscoveryError,
|
|
33
45
|
OAUTH_MAX_RESPONSE_BYTES,
|
|
34
46
|
RequestDeadlineError,
|
|
35
47
|
isLocalTestEnvironment,
|
|
48
|
+
parseMcpOAuthChallenge,
|
|
36
49
|
pinnedFetch,
|
|
37
50
|
readResponseJsonBounded,
|
|
51
|
+
resolveMcpOAuthDiscovery,
|
|
38
52
|
validateHttpUrl,
|
|
53
|
+
type McpAuthorizationServerMetadata,
|
|
54
|
+
type McpOAuthChallenge,
|
|
55
|
+
type McpOAuthDiscoveryMode,
|
|
56
|
+
type McpOAuthMetadataFetchResult,
|
|
57
|
+
type McpProtectedResourceMetadata,
|
|
39
58
|
} from "@opengeni/network";
|
|
40
59
|
import { Buffer } from "node:buffer";
|
|
41
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
60
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
42
61
|
import { HTTPException } from "hono/http-exception";
|
|
62
|
+
import {
|
|
63
|
+
assertConnectionOwnershipAllowedForPrincipal,
|
|
64
|
+
personalOwnerStateAccepted,
|
|
65
|
+
personalOwnerVerifiedInState,
|
|
66
|
+
PERSONAL_CONNECTION_PRINCIPAL_MESSAGE,
|
|
67
|
+
PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
|
|
68
|
+
} from "../connection-ownership";
|
|
43
69
|
import { ApiHttpError } from "../http/api-error";
|
|
70
|
+
import { requireConnectOwnerAuthority } from "./connect-authority";
|
|
71
|
+
import {
|
|
72
|
+
DEFAULT_OAUTH_PROFILE,
|
|
73
|
+
OFFICIAL_GMAIL_MCP_URL,
|
|
74
|
+
DEPLOYMENT_MANAGED_CLIENTS,
|
|
75
|
+
builtInOAuthProfileByKey,
|
|
76
|
+
assertAuthorizationServerNotReserved,
|
|
77
|
+
assertAuthorizationServerPins,
|
|
78
|
+
builtInOAuthProfileFor,
|
|
79
|
+
catalogMcpUrlKey,
|
|
80
|
+
defaultOwnershipFor,
|
|
81
|
+
deploymentManagedClientFor,
|
|
82
|
+
oauthProfileFromCatalog,
|
|
83
|
+
type OAuthProviderProfile,
|
|
84
|
+
type PinnableAuthorizationServer,
|
|
85
|
+
} from "./oauth-profiles";
|
|
44
86
|
import { canonicalProviderDomain } from "./provider-domain";
|
|
45
87
|
|
|
46
88
|
export const oauthStateTtlMs = 10 * 60 * 1000;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Workspace-free landing for a callback whose state names no trustworthy
|
|
92
|
+
* workspace. The web app resolves it to the viewer's current workspace
|
|
93
|
+
* integrations page and keeps the callback outcome parameters.
|
|
94
|
+
*/
|
|
95
|
+
export const INTEGRATIONS_FALLBACK_PATH = "/integrations";
|
|
96
|
+
|
|
97
|
+
const WORKSPACE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
98
|
+
|
|
99
|
+
/** The web integrations page for one workspace (the Plugins surface). */
|
|
100
|
+
export function workspaceIntegrationsPath(workspaceId: string): string {
|
|
101
|
+
return `/workspaces/${encodeURIComponent(workspaceId)}/plugins`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The integrations page for a workspace id read from untrusted input (a URL
|
|
106
|
+
* parameter or an unverified-age state), or null unless it is a workspace UUID.
|
|
107
|
+
* A link target only: it grants nothing.
|
|
108
|
+
*/
|
|
109
|
+
export function workspaceIntegrationsPathForUntrusted(candidate: unknown): string | null {
|
|
110
|
+
return typeof candidate === "string" && WORKSPACE_ID_PATTERN.test(candidate)
|
|
111
|
+
? workspaceIntegrationsPath(candidate)
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Where a callback returns, and why, when its own signed state is unusable.
|
|
117
|
+
*
|
|
118
|
+
* A correctly signed state still names its workspace, even when it is too old
|
|
119
|
+
* or its flow rejected it, so the browser returns to that workspace's
|
|
120
|
+
* integrations page; an aged one is reported as expired. Anything unsigned,
|
|
121
|
+
* tampered, or signed with another secret returns to
|
|
122
|
+
* {@link INTEGRATIONS_FALLBACK_PATH}. Display routing only: nothing here
|
|
123
|
+
* authorizes, resumes, or replays the callback.
|
|
124
|
+
*/
|
|
125
|
+
export function oauthStateFailureReturn(
|
|
126
|
+
settings: Settings,
|
|
127
|
+
rawState: string | undefined,
|
|
128
|
+
nowMs = Date.now(),
|
|
129
|
+
): { returnPath: string; reason: "state_expired" | "state_invalid" } {
|
|
130
|
+
let payload: ReturnType<typeof inspectSignedState> = null;
|
|
131
|
+
try {
|
|
132
|
+
payload = rawState
|
|
133
|
+
? inspectSignedState(rawState, requireIntegrationsStateSecret(settings))
|
|
134
|
+
: null;
|
|
135
|
+
} catch {
|
|
136
|
+
payload = null;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
returnPath:
|
|
140
|
+
workspaceIntegrationsPathForUntrusted(payload?.workspaceId) ?? INTEGRATIONS_FALLBACK_PATH,
|
|
141
|
+
reason:
|
|
142
|
+
payload !== null && nowMs - payload.iat * 1000 > oauthStateTtlMs
|
|
143
|
+
? "state_expired"
|
|
144
|
+
: "state_invalid",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export {
|
|
148
|
+
OFFICIAL_GMAIL_MCP_SCOPES,
|
|
149
|
+
OFFICIAL_GMAIL_MCP_URL,
|
|
150
|
+
OFFICIAL_SLACK_MCP_URL,
|
|
151
|
+
} from "./oauth-profiles";
|
|
58
152
|
export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
|
|
59
153
|
|
|
60
154
|
type OAuthClientDeps = {
|
|
@@ -66,41 +160,32 @@ type OAuthClientDeps = {
|
|
|
66
160
|
};
|
|
67
161
|
|
|
68
162
|
export type OAuthStartContext = {
|
|
163
|
+
/** Server-selected dedicated adapter identity; never copied from request metadata. */
|
|
164
|
+
integrationKey?: "gmail" | "slack-personal";
|
|
165
|
+
connectAttemptId?: string;
|
|
166
|
+
externalContinuation?: ExternalActorContinuation;
|
|
69
167
|
accountId: string;
|
|
70
168
|
workspaceId: string;
|
|
71
169
|
subjectId: string;
|
|
170
|
+
/**
|
|
171
|
+
* False for every principal that cannot own a personal Connection (API keys,
|
|
172
|
+
* the configured key, services, agent attempts). Resolved by the route from
|
|
173
|
+
* the live authenticated principal, never inferred here.
|
|
174
|
+
*/
|
|
175
|
+
personalOwnershipAllowed: boolean;
|
|
72
176
|
requestUrl: string;
|
|
73
177
|
payload: OAuthStartRequest;
|
|
74
178
|
};
|
|
75
179
|
|
|
76
180
|
export type OAuthCallbackResult = {
|
|
77
181
|
redirectTo: string;
|
|
182
|
+
exactReturn?: boolean;
|
|
78
183
|
};
|
|
79
184
|
|
|
80
|
-
type WwwAuthenticateChallenge =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
type ProtectedResourceMetadata = {
|
|
87
|
-
resource?: string;
|
|
88
|
-
authorizationServers: string[];
|
|
89
|
-
scopesSupported: string[];
|
|
90
|
-
raw: Record<string, unknown>;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
type AuthorizationServerMetadata = {
|
|
94
|
-
issuer: string;
|
|
95
|
-
authorizationServer: string;
|
|
96
|
-
authorizationEndpoint: string;
|
|
97
|
-
tokenEndpoint: string;
|
|
98
|
-
registrationEndpoint?: string;
|
|
99
|
-
clientIdMetadataDocumentSupported: boolean;
|
|
100
|
-
tokenEndpointAuthMethodsSupported: string[];
|
|
101
|
-
codeChallengeMethodsSupported: string[];
|
|
102
|
-
raw: Record<string, unknown>;
|
|
103
|
-
};
|
|
185
|
+
type WwwAuthenticateChallenge = McpOAuthChallenge;
|
|
186
|
+
type ProtectedResourceMetadata = McpProtectedResourceMetadata;
|
|
187
|
+
type AuthorizationServerMetadata = McpAuthorizationServerMetadata;
|
|
188
|
+
type OAuthDiscoveryMode = McpOAuthDiscoveryMode | "provider_oauth_metadata";
|
|
104
189
|
|
|
105
190
|
type OAuthClientRegistration = {
|
|
106
191
|
method: "operator" | "manual" | "cimd" | "dcr";
|
|
@@ -112,10 +197,16 @@ type OAuthClientRegistration = {
|
|
|
112
197
|
};
|
|
113
198
|
|
|
114
199
|
type OAuthStatePayload = {
|
|
200
|
+
integrationKey?: "gmail" | "slack-personal";
|
|
201
|
+
connectAttemptId?: string;
|
|
202
|
+
externalContinuation?: ExternalActorContinuation;
|
|
203
|
+
returnUrl?: string;
|
|
115
204
|
accountId: string;
|
|
116
205
|
workspaceId: string;
|
|
117
206
|
subjectId: string;
|
|
118
207
|
ownership: ConnectionOwnership;
|
|
208
|
+
/** Signed proof that a live managed human authorized personal ownership. */
|
|
209
|
+
personalOwnerVerified: boolean;
|
|
119
210
|
providerDomain: string;
|
|
120
211
|
mcpUrl: string;
|
|
121
212
|
resource: string;
|
|
@@ -126,6 +217,10 @@ type OAuthStatePayload = {
|
|
|
126
217
|
tokenEndpoint: string;
|
|
127
218
|
authorizationServer: string;
|
|
128
219
|
issuer: string;
|
|
220
|
+
discoveryMode: OAuthDiscoveryMode;
|
|
221
|
+
discoveryMetadataSha256?: string;
|
|
222
|
+
protectedResourceMetadataUrl?: string;
|
|
223
|
+
authorizationServerMetadataUrl?: string;
|
|
129
224
|
clientRegistrationMethod: OAuthClientRegistration["method"];
|
|
130
225
|
tokenEndpointAuthMethod: OAuthClientRegistration["tokenEndpointAuthMethod"];
|
|
131
226
|
resourceParameterSupported: boolean;
|
|
@@ -162,7 +257,8 @@ export type OAuthStartStage =
|
|
|
162
257
|
| "mcp_challenge"
|
|
163
258
|
| "protected_resource_metadata"
|
|
164
259
|
| "authorization_server_metadata"
|
|
165
|
-
| "client_registration"
|
|
260
|
+
| "client_registration"
|
|
261
|
+
| "state_persist";
|
|
166
262
|
|
|
167
263
|
class OAuthStartStageError extends Error {
|
|
168
264
|
constructor(
|
|
@@ -175,6 +271,13 @@ class OAuthStartStageError extends Error {
|
|
|
175
271
|
}
|
|
176
272
|
}
|
|
177
273
|
|
|
274
|
+
class OAuthMetadataUpstreamError extends Error {
|
|
275
|
+
constructor(readonly upstreamStatus: number) {
|
|
276
|
+
super(`OAuth metadata endpoint returned HTTP ${upstreamStatus}`);
|
|
277
|
+
this.name = "OAuthMetadataUpstreamError";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
178
281
|
class OAuthStartDeadline {
|
|
179
282
|
readonly signal: AbortSignal;
|
|
180
283
|
private readonly controller = new AbortController();
|
|
@@ -279,10 +382,90 @@ class OAuthCallbackDeadline {
|
|
|
279
382
|
}
|
|
280
383
|
}
|
|
281
384
|
|
|
385
|
+
/** Policy is the outermost lock. Receipt replay still runs normal authorization,
|
|
386
|
+
* but the DB calls authorizeAcquisition only for uncommitted progress. Provider
|
|
387
|
+
* requests must happen after this transaction, never inside either wrapper. */
|
|
388
|
+
export async function claimOAuthAcquisition(
|
|
389
|
+
db: Database,
|
|
390
|
+
scope: Parameters<typeof claimConnectOperation>[1],
|
|
391
|
+
input: Parameters<typeof claimConnectOperation>[2],
|
|
392
|
+
integrationKey: string | null,
|
|
393
|
+
acquiring = true,
|
|
394
|
+
) {
|
|
395
|
+
if (!acquiring) return claimConnectOperation(db, scope, input);
|
|
396
|
+
return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
|
|
397
|
+
claimConnectOperation(tx, scope, {
|
|
398
|
+
...input,
|
|
399
|
+
authorizeAcquisition: async (locked, attempt) => {
|
|
400
|
+
assertOrganizationIntegrationAllowed(policy, integrationKey);
|
|
401
|
+
await input.authorizeAcquisition?.(locked, attempt);
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
type IntegrationSourceSelection = Record<string, unknown> & {
|
|
408
|
+
id: string;
|
|
409
|
+
syncEnabled: boolean;
|
|
410
|
+
readPolicy: "allow" | "ask" | "block";
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** Compare server-bound selections, not provider classification. Removing a
|
|
414
|
+
* source, disabling sync, or narrowing its read permission acquires no authority.
|
|
415
|
+
* Generation/timestamp bookkeeping does not turn an unchanged save into setup. */
|
|
416
|
+
export function integrationSourceSelectionRequiresAcquisition(
|
|
417
|
+
previous: readonly IntegrationSourceSelection[],
|
|
418
|
+
requested: readonly IntegrationSourceSelection[],
|
|
419
|
+
): boolean {
|
|
420
|
+
const rank = { block: 0, ask: 1, allow: 2 };
|
|
421
|
+
const config = (source: IntegrationSourceSelection) => {
|
|
422
|
+
const {
|
|
423
|
+
selectedAt: _selectedAt,
|
|
424
|
+
configGeneration: _generation,
|
|
425
|
+
syncEnabled: _enabled,
|
|
426
|
+
readPolicy: _readPolicy,
|
|
427
|
+
...binding
|
|
428
|
+
} = source;
|
|
429
|
+
return stableJson(binding);
|
|
430
|
+
};
|
|
431
|
+
return requested.some((source) => {
|
|
432
|
+
const existing = previous.find((candidate) => candidate.id === source.id);
|
|
433
|
+
return (
|
|
434
|
+
!existing ||
|
|
435
|
+
(!existing.syncEnabled && source.syncEnabled) ||
|
|
436
|
+
rank[source.readPolicy] > rank[existing.readPolicy] ||
|
|
437
|
+
config(existing) !== config(source)
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function finishOAuthAcquisition(
|
|
443
|
+
db: Database,
|
|
444
|
+
scope: Parameters<typeof finishConnectOperation>[1],
|
|
445
|
+
input: Parameters<typeof finishConnectOperation>[2],
|
|
446
|
+
integrationKey: string | null,
|
|
447
|
+
) {
|
|
448
|
+
return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
|
|
449
|
+
finishConnectOperation(tx, scope, {
|
|
450
|
+
...input,
|
|
451
|
+
authorizeAcquisition: async (locked, attempt) => {
|
|
452
|
+
assertOrganizationIntegrationAllowed(policy, integrationKey);
|
|
453
|
+
await input.authorizeAcquisition?.(locked, attempt);
|
|
454
|
+
},
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
282
459
|
export async function startMcpOAuth(
|
|
283
460
|
deps: OAuthClientDeps,
|
|
284
461
|
context: OAuthStartContext,
|
|
285
462
|
): Promise<OAuthStartResponse> {
|
|
463
|
+
await withOrganizationIntegrationAcquisition(
|
|
464
|
+
deps.db,
|
|
465
|
+
context,
|
|
466
|
+
[context.integrationKey ?? "custom:mcp"],
|
|
467
|
+
async () => {},
|
|
468
|
+
);
|
|
286
469
|
const deadline = new OAuthStartDeadline(deps.oauthStartDeadlineMs ?? OAUTH_START_DEADLINE_MS);
|
|
287
470
|
try {
|
|
288
471
|
return await startMcpOAuthWithinDeadline(deps, context, deadline);
|
|
@@ -304,17 +487,51 @@ async function startMcpOAuthWithinDeadline(
|
|
|
304
487
|
deadline: OAuthStartDeadline,
|
|
305
488
|
): Promise<OAuthStartResponse> {
|
|
306
489
|
const { db, settings } = deps;
|
|
490
|
+
const externalContinuation = context.externalContinuation
|
|
491
|
+
? ExternalActorContinuation.parse(context.externalContinuation)
|
|
492
|
+
: undefined;
|
|
493
|
+
const returnUrl =
|
|
494
|
+
context.payload.returnUrl !== undefined
|
|
495
|
+
? exactExternalReturnUrl(context.payload.returnUrl)
|
|
496
|
+
: undefined;
|
|
497
|
+
if (
|
|
498
|
+
(externalContinuation && !returnUrl) ||
|
|
499
|
+
(returnUrl && !externalContinuation && !context.connectAttemptId)
|
|
500
|
+
)
|
|
501
|
+
throw new HTTPException(422, {
|
|
502
|
+
message: "external MCP OAuth requires verified actor authority and a host returnUrl",
|
|
503
|
+
});
|
|
504
|
+
if (
|
|
505
|
+
externalContinuation &&
|
|
506
|
+
(externalContinuation.actor.accountId !== context.accountId ||
|
|
507
|
+
externalContinuation.actor.effectiveSubjectId !== context.subjectId)
|
|
508
|
+
)
|
|
509
|
+
throw new HTTPException(403, { message: "external OAuth actor mismatch" });
|
|
307
510
|
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
511
|
+
const urlProfile = builtInOAuthProfileFor({ mcpUrl });
|
|
512
|
+
const providerDomain =
|
|
513
|
+
urlProfile?.canonicalProviderDomain ??
|
|
514
|
+
canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
|
|
515
|
+
const builtInProfile = urlProfile ?? builtInOAuthProfileFor({ mcpUrl, providerDomain });
|
|
516
|
+
const profile =
|
|
517
|
+
builtInProfile ??
|
|
518
|
+
(await deadline.run("connection_lookup", async () => {
|
|
519
|
+
const raw = await getGlobalCatalogOAuthProfile(
|
|
520
|
+
db,
|
|
521
|
+
context.workspaceId,
|
|
522
|
+
catalogMcpUrlKey(mcpUrl),
|
|
523
|
+
);
|
|
524
|
+
return raw === null ? null : oauthProfileFromCatalog(mcpUrl, raw);
|
|
525
|
+
})) ??
|
|
526
|
+
DEFAULT_OAUTH_PROFILE;
|
|
527
|
+
assertOAuthStartProfile(settings, context.payload, mcpUrl, profile);
|
|
528
|
+
// Catalog defaults are setup preferences; an explicit ownership always wins.
|
|
529
|
+
const requestedOwnership: ConnectionOwnership =
|
|
530
|
+
context.payload.ownership ?? defaultOwnershipFor(profile);
|
|
531
|
+
assertProfileOwnership(profile, requestedOwnership);
|
|
532
|
+
const returnPath = safeReturnPath(
|
|
533
|
+
context.payload.returnPath ?? workspaceIntegrationsPath(context.workspaceId),
|
|
534
|
+
);
|
|
318
535
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
319
536
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
320
537
|
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
@@ -324,8 +541,8 @@ async function startMcpOAuthWithinDeadline(
|
|
|
324
541
|
subjectId: context.subjectId,
|
|
325
542
|
providerDomain,
|
|
326
543
|
mcpUrl,
|
|
327
|
-
|
|
328
|
-
exactMcpBinding:
|
|
544
|
+
connectionSelection: profile.connectionSelection,
|
|
545
|
+
exactMcpBinding: profile.exactMcpBinding,
|
|
329
546
|
connectionId: context.payload.connectionId,
|
|
330
547
|
requestedOwnership: context.payload.ownership,
|
|
331
548
|
newConnectionOwnership: requestedOwnership,
|
|
@@ -337,30 +554,29 @@ async function startMcpOAuthWithinDeadline(
|
|
|
337
554
|
const ownership = existing
|
|
338
555
|
? ownershipForConnection(existing.subjectId, context.subjectId)
|
|
339
556
|
: requestedOwnership;
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
557
|
+
assertProfileOwnership(profile, ownership);
|
|
558
|
+
// Only a managed human can own a personal Connection: personal-authority
|
|
559
|
+
// execution resolves through a delegation snapshot frozen on a human's causal
|
|
560
|
+
// turn, and migration 0256 can mint the `user` authority scope only for a
|
|
561
|
+
// subject that holds an active organization membership. An explicit personal
|
|
562
|
+
// choice is never silently downgraded to workspace ownership.
|
|
563
|
+
assertConnectionOwnershipAllowedForPrincipal(ownership, context.personalOwnershipAllowed);
|
|
564
|
+
|
|
565
|
+
const discovery = profile.providerOAuthDiscovery
|
|
566
|
+
? await discoverProviderOAuth(mcpUrl, settings, deadline, profile)
|
|
567
|
+
: await discoverMcpOAuth(mcpUrl, settings, deadline);
|
|
568
|
+
assertDiscoveredAuthorizationServer(settings, discovery.as, profile, providerDomain);
|
|
569
|
+
const resourceParameterSupported =
|
|
570
|
+
discovery.mode === "rfc9728_protected_resource" && profile.sendResourceParameter;
|
|
571
|
+
const resource = discovery.resource;
|
|
356
572
|
const verifier = randomPkceVerifier();
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
const authorizeScopes =
|
|
360
|
-
mcpUrl,
|
|
573
|
+
// A profile's exact scope override wins outright: the reviewed connector
|
|
574
|
+
// never lets a caller widen the capability contract.
|
|
575
|
+
const authorizeScopes = chooseProfileAuthorizeScopes(profile, {
|
|
361
576
|
requested: context.payload.requestedScopes,
|
|
362
577
|
challenged: discovery.challenge.scope,
|
|
363
578
|
supported: discovery.prm.scopesSupported,
|
|
579
|
+
authorizationServerScopesSupported: stringArray(discovery.as.raw.scopes_supported),
|
|
364
580
|
});
|
|
365
581
|
const client = await deadline.run("client_registration", (signal) =>
|
|
366
582
|
registerOAuthClient(
|
|
@@ -371,20 +587,50 @@ async function startMcpOAuthWithinDeadline(
|
|
|
371
587
|
redirectUri,
|
|
372
588
|
authorizeScopes,
|
|
373
589
|
context.payload.oauthClient,
|
|
590
|
+
profile,
|
|
374
591
|
signal,
|
|
592
|
+
context,
|
|
375
593
|
),
|
|
376
594
|
);
|
|
595
|
+
if (
|
|
596
|
+
profile.confidentialClientRequired &&
|
|
597
|
+
(!isConfidentialOAuthClient(client) ||
|
|
598
|
+
!discovery.as.tokenEndpointAuthMethodsSupported.includes(client.tokenEndpointAuthMethod))
|
|
599
|
+
) {
|
|
600
|
+
throw new OAuthStartStageError(
|
|
601
|
+
"client_registration",
|
|
602
|
+
"configuration_missing",
|
|
603
|
+
new HTTPException(503, {
|
|
604
|
+
message:
|
|
605
|
+
"Gmail OAuth requires a registered Google Web application client with a secret and supported token authentication",
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
}
|
|
377
609
|
const key = requireEnvironmentEncryption(settings);
|
|
378
|
-
const
|
|
610
|
+
const fullState = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
611
|
+
...(context.integrationKey ? { integrationKey: context.integrationKey } : {}),
|
|
612
|
+
...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}),
|
|
379
613
|
accountId: context.accountId,
|
|
380
614
|
workspaceId: context.workspaceId,
|
|
381
615
|
subjectId: context.subjectId,
|
|
382
616
|
ownership,
|
|
617
|
+
// Signed record that a live principal was checked; the callback has no
|
|
618
|
+
// principal of its own and enforces exactly this decision.
|
|
619
|
+
[PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: context.personalOwnershipAllowed,
|
|
620
|
+
...(externalContinuation
|
|
621
|
+
? {
|
|
622
|
+
encryptedExternalContinuation: encryptEnvironmentValue(
|
|
623
|
+
requireEnvironmentEncryption(settings),
|
|
624
|
+
JSON.stringify(externalContinuation),
|
|
625
|
+
),
|
|
626
|
+
}
|
|
627
|
+
: {}),
|
|
628
|
+
...(returnUrl ? { returnUrl } : {}),
|
|
383
629
|
providerDomain,
|
|
384
630
|
mcpUrl,
|
|
385
631
|
resource,
|
|
386
|
-
requestedScopes:
|
|
387
|
-
? [...
|
|
632
|
+
requestedScopes: profile.requestedScopes
|
|
633
|
+
? [...profile.requestedScopes]
|
|
388
634
|
: uniqueStrings(context.payload.requestedScopes ?? []),
|
|
389
635
|
authorizeScopes,
|
|
390
636
|
encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
|
|
@@ -392,6 +638,14 @@ async function startMcpOAuthWithinDeadline(
|
|
|
392
638
|
tokenEndpoint: discovery.as.tokenEndpoint,
|
|
393
639
|
authorizationServer: client.authorizationServer,
|
|
394
640
|
issuer: client.issuer,
|
|
641
|
+
discoveryMode: discovery.mode,
|
|
642
|
+
discoveryMetadataSha256: discovery.provenance.metadataSha256,
|
|
643
|
+
...(discovery.provenance.protectedResourceMetadataUrl
|
|
644
|
+
? {
|
|
645
|
+
protectedResourceMetadataUrl: discovery.provenance.protectedResourceMetadataUrl,
|
|
646
|
+
}
|
|
647
|
+
: {}),
|
|
648
|
+
authorizationServerMetadataUrl: discovery.provenance.authorizationServerMetadataUrl,
|
|
395
649
|
clientRegistrationMethod: client.method,
|
|
396
650
|
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
|
397
651
|
resourceParameterSupported,
|
|
@@ -403,6 +657,29 @@ async function startMcpOAuthWithinDeadline(
|
|
|
403
657
|
returnPath,
|
|
404
658
|
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
405
659
|
});
|
|
660
|
+
const expiresAt = new Date(Date.now() + oauthStateTtlMs);
|
|
661
|
+
let state = fullState;
|
|
662
|
+
if (settings.integrationsOauthShortStateEnabled) {
|
|
663
|
+
const stateId = randomUUID();
|
|
664
|
+
state = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
665
|
+
kind: "mcp_oauth_reference",
|
|
666
|
+
id: stateId,
|
|
667
|
+
accountId: context.accountId,
|
|
668
|
+
workspaceId: context.workspaceId,
|
|
669
|
+
});
|
|
670
|
+
if (state.length > 512) {
|
|
671
|
+
throw new Error("MCP OAuth reference state exceeded its provider-safe length");
|
|
672
|
+
}
|
|
673
|
+
await deadline.run("state_persist", async () =>
|
|
674
|
+
storeIntegrationOAuthPendingState(db, {
|
|
675
|
+
id: stateId,
|
|
676
|
+
accountId: context.accountId,
|
|
677
|
+
workspaceId: context.workspaceId,
|
|
678
|
+
stateEncrypted: encryptEnvironmentValue(key, fullState),
|
|
679
|
+
expiresAt,
|
|
680
|
+
}),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
406
683
|
const authorizationUrl = buildAuthorizationUrl({
|
|
407
684
|
endpoint: discovery.as.authorizationEndpoint,
|
|
408
685
|
settings,
|
|
@@ -413,31 +690,84 @@ async function startMcpOAuthWithinDeadline(
|
|
|
413
690
|
verifier,
|
|
414
691
|
scopes: authorizeScopes,
|
|
415
692
|
resourceParameterSupported,
|
|
693
|
+
...(profile.extraAuthorizeParams ? { extraParams: profile.extraAuthorizeParams } : {}),
|
|
416
694
|
});
|
|
417
695
|
return OAuthStartResponse.parse({
|
|
418
696
|
state,
|
|
419
697
|
authorizationUrl,
|
|
420
|
-
expiresAt:
|
|
698
|
+
expiresAt: expiresAt.toISOString(),
|
|
421
699
|
});
|
|
422
700
|
}
|
|
423
701
|
|
|
424
|
-
export function
|
|
702
|
+
export function isHostedSlackMcpTarget(providerDomain: string, mcpUrl: string): boolean {
|
|
703
|
+
return builtInOAuthProfileFor({ mcpUrl, providerDomain })?.key === "hosted-slack-mcp";
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Start-time payload fences declared by the resolved profile. */
|
|
707
|
+
function assertOAuthStartProfile(
|
|
708
|
+
settings: Settings,
|
|
709
|
+
payload: OAuthStartRequest,
|
|
425
710
|
mcpUrl: string,
|
|
426
|
-
|
|
711
|
+
profile: OAuthProviderProfile,
|
|
427
712
|
): void {
|
|
428
|
-
if (
|
|
429
|
-
throw new HTTPException(422, {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
713
|
+
if (profile.rejectCallerOAuthClient && payload.oauthClient) {
|
|
714
|
+
throw new HTTPException(422, { message: profile.rejectCallerOAuthClient.message });
|
|
715
|
+
}
|
|
716
|
+
if (
|
|
717
|
+
profile.requireProviderDomain &&
|
|
718
|
+
payload.providerDomain &&
|
|
719
|
+
canonicalProviderDomain(payload.providerDomain) !== profile.requireProviderDomain.domain
|
|
720
|
+
) {
|
|
721
|
+
throw new HTTPException(422, { message: profile.requireProviderDomain.message });
|
|
722
|
+
}
|
|
723
|
+
if (
|
|
724
|
+
profile.requireExactMcpUrl &&
|
|
725
|
+
!isLocalTestEnvironment(settings.environment) &&
|
|
726
|
+
mcpUrl !== profile.requireExactMcpUrl.url
|
|
727
|
+
) {
|
|
728
|
+
throw new HTTPException(422, { message: profile.requireExactMcpUrl.message });
|
|
729
|
+
}
|
|
730
|
+
if (
|
|
731
|
+
profile.requireDeploymentClient &&
|
|
732
|
+
!deploymentManagedClientFor(settings, profile.requireDeploymentClient.key)
|
|
733
|
+
) {
|
|
734
|
+
throw new HTTPException(503, { message: profile.requireDeploymentClient.message });
|
|
433
735
|
}
|
|
434
736
|
}
|
|
435
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Post-discovery fences, in an order that preserves the historical error
|
|
740
|
+
* surface: the profile's provider identity, then its authorization-server
|
|
741
|
+
* origin pins, then the reserved-server guard for everything else.
|
|
742
|
+
*/
|
|
743
|
+
function assertDiscoveredAuthorizationServer(
|
|
744
|
+
settings: Settings,
|
|
745
|
+
as: PinnableAuthorizationServer,
|
|
746
|
+
profile: OAuthProviderProfile,
|
|
747
|
+
providerDomain: string,
|
|
748
|
+
): void {
|
|
749
|
+
if (
|
|
750
|
+
profile.postDiscoveryProviderDomain &&
|
|
751
|
+
providerDomain !== profile.postDiscoveryProviderDomain.domain
|
|
752
|
+
) {
|
|
753
|
+
throw new HTTPException(422, { message: profile.postDiscoveryProviderDomain.message });
|
|
754
|
+
}
|
|
755
|
+
if (
|
|
756
|
+
profile.authorizationServer &&
|
|
757
|
+
!(profile.authorizationServer.skipInLocalTest && isLocalTestEnvironment(settings.environment))
|
|
758
|
+
) {
|
|
759
|
+
assertAuthorizationServerPins(as, profile.authorizationServer);
|
|
760
|
+
}
|
|
761
|
+
assertAuthorizationServerNotReserved(as, profile);
|
|
762
|
+
}
|
|
763
|
+
|
|
436
764
|
export async function completeMcpOAuthCallback(
|
|
437
765
|
deps: OAuthClientDeps,
|
|
438
766
|
input: {
|
|
439
767
|
code?: string | undefined;
|
|
440
768
|
state?: string | undefined;
|
|
769
|
+
/** The provider's `error` parameter, for example `access_denied` on Cancel. */
|
|
770
|
+
error?: string | undefined;
|
|
441
771
|
requestUrl: string;
|
|
442
772
|
},
|
|
443
773
|
): Promise<OAuthCallbackResult> {
|
|
@@ -456,12 +786,15 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
456
786
|
input: {
|
|
457
787
|
code?: string | undefined;
|
|
458
788
|
state?: string | undefined;
|
|
789
|
+
error?: string | undefined;
|
|
459
790
|
requestUrl: string;
|
|
460
791
|
},
|
|
461
792
|
deadline: OAuthCallbackDeadline,
|
|
462
793
|
): Promise<OAuthCallbackResult> {
|
|
463
794
|
const { db, settings, observability } = deps;
|
|
464
795
|
let state: OAuthStatePayload | null = null;
|
|
796
|
+
let integrationKey: string | null = "custom:mcp";
|
|
797
|
+
let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
|
|
465
798
|
if (!input.state) {
|
|
466
799
|
const error = new OAuthCallbackStageError(
|
|
467
800
|
"state_verify",
|
|
@@ -470,25 +803,94 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
470
803
|
);
|
|
471
804
|
logOAuthCallbackFailure(observability, error, state);
|
|
472
805
|
return {
|
|
473
|
-
redirectTo: callbackReturnPath(
|
|
806
|
+
redirectTo: callbackReturnPath(INTEGRATIONS_FALLBACK_PATH, "error", {
|
|
474
807
|
stage: error.stage,
|
|
475
808
|
reason: error.reason,
|
|
476
809
|
}),
|
|
477
810
|
};
|
|
478
811
|
}
|
|
479
812
|
try {
|
|
480
|
-
state =
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
813
|
+
state = await resolveMcpOAuthState(db, settings, input.state, deadline);
|
|
814
|
+
integrationKey = state.integrationKey ?? "custom:mcp";
|
|
815
|
+
if (state.connectAttemptId) {
|
|
816
|
+
const stored = await getConnectAttempt(db, state, state.connectAttemptId);
|
|
817
|
+
// Native Gmail setup uses its own provider id, but shares this callback.
|
|
818
|
+
// Bind that id to the exact reviewed Gmail destination before
|
|
819
|
+
// claiming an operation (including receipt replay), not to arbitrary MCP.
|
|
820
|
+
const gmailAttemptMatches =
|
|
821
|
+
stored.attempt.providerId === "gmail" &&
|
|
822
|
+
state.mcpUrl === OFFICIAL_GMAIL_MCP_URL &&
|
|
823
|
+
state.providerDomain === "gmailmcp.googleapis.com" &&
|
|
824
|
+
builtInOAuthProfileFor(state)?.key === "official-gmail" &&
|
|
825
|
+
personalOwnerStateAccepted(state);
|
|
826
|
+
if (
|
|
827
|
+
(!["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) &&
|
|
828
|
+
!gmailAttemptMatches) ||
|
|
829
|
+
stored.attempt.ownership !== state.ownership ||
|
|
830
|
+
stored.returnUrl !== state.returnUrl
|
|
831
|
+
)
|
|
832
|
+
throw new HTTPException(403, { message: "OAuth attempt mismatch" });
|
|
833
|
+
connectOperation = {
|
|
834
|
+
attemptId: state.connectAttemptId,
|
|
835
|
+
operationId: `oauth:${state.nonce}`,
|
|
836
|
+
inputDigest: createHash("sha256").update(input.state).digest("hex"),
|
|
490
837
|
};
|
|
838
|
+
integrationKey = integrationKeyForConnectProvider(stored.attempt.providerId);
|
|
839
|
+
const claim = await claimOAuthAcquisition(
|
|
840
|
+
db,
|
|
841
|
+
state,
|
|
842
|
+
{
|
|
843
|
+
...connectOperation,
|
|
844
|
+
expectedRevision: stored.attempt.revision,
|
|
845
|
+
authorize: (tx, _attempt, origin) =>
|
|
846
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
847
|
+
},
|
|
848
|
+
integrationKey,
|
|
849
|
+
Boolean(input.code),
|
|
850
|
+
);
|
|
851
|
+
if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
|
|
491
852
|
}
|
|
853
|
+
// The callback enforces the signed start-time principal decision.
|
|
854
|
+
// Workspace ownership requires no personal-owner claim.
|
|
855
|
+
if (!personalOwnerStateAccepted(state)) {
|
|
856
|
+
throw new OAuthCallbackStageError(
|
|
857
|
+
"state_verify",
|
|
858
|
+
"state_invalid",
|
|
859
|
+
new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE }),
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
if (input.error || !input.code) {
|
|
863
|
+
// The provider sends `error` (for example `access_denied` when the user
|
|
864
|
+
// clicks Cancel) instead of a code. Report that as a refusal, not as an
|
|
865
|
+
// expired attempt, and never echo the provider's own text.
|
|
866
|
+
const cancelled = input.error === "access_denied";
|
|
867
|
+
if (connectOperation) {
|
|
868
|
+
await finishConnectOperation(db, state, {
|
|
869
|
+
...connectOperation,
|
|
870
|
+
authorize: (tx, _attempt, origin) =>
|
|
871
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
872
|
+
commit: async (_tx, current) => ({
|
|
873
|
+
...current,
|
|
874
|
+
revision: current.revision + 1,
|
|
875
|
+
state: cancelled ? "cancelled" : "failed",
|
|
876
|
+
nextAction: { type: "none" },
|
|
877
|
+
error: {
|
|
878
|
+
code: input.error ? "provider_denied" : "missing_code",
|
|
879
|
+
message: "Authorization was not completed. Start a new connection attempt.",
|
|
880
|
+
retryable: false,
|
|
881
|
+
},
|
|
882
|
+
}),
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
return callbackStateResult(
|
|
886
|
+
state,
|
|
887
|
+
"error",
|
|
888
|
+
input.error
|
|
889
|
+
? { stage: "authorize", reason: cancelled ? "access_denied" : "provider_error" }
|
|
890
|
+
: { stage: "state_verify", reason: "missing_code" },
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
await withOrganizationIntegrationAcquisition(db, state, [integrationKey], async () => {});
|
|
492
894
|
const consumed = await runCallbackDatabaseStage(
|
|
493
895
|
deadline,
|
|
494
896
|
"state_verify",
|
|
@@ -516,12 +918,21 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
516
918
|
? error
|
|
517
919
|
: new OAuthCallbackStageError("state_verify", "state_invalid", error);
|
|
518
920
|
logOAuthCallbackFailure(observability, staged, state);
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
921
|
+
if (!state) {
|
|
922
|
+
// The state itself could not be resolved, so it carries no return path.
|
|
923
|
+
// An authentic but aged state still names its workspace and "expired".
|
|
924
|
+
const failure = oauthStateFailureReturn(settings, input.state);
|
|
925
|
+
return {
|
|
926
|
+
redirectTo: callbackReturnPath(failure.returnPath, "error", {
|
|
927
|
+
stage: staged.stage,
|
|
928
|
+
reason: staged.reason === "state_invalid" ? failure.reason : staged.reason,
|
|
929
|
+
}),
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
return callbackStateResult(state, "error", {
|
|
933
|
+
stage: staged.stage,
|
|
934
|
+
reason: staged.reason,
|
|
935
|
+
});
|
|
525
936
|
}
|
|
526
937
|
|
|
527
938
|
const ownerSubjectId = state.ownership === "personal" ? state.subjectId : null;
|
|
@@ -533,6 +944,16 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
533
944
|
const client = await runCallbackDatabaseStage(deadline, "client_lookup", db, (scopedDb) =>
|
|
534
945
|
clientForState(scopedDb, settings, state),
|
|
535
946
|
);
|
|
947
|
+
if (
|
|
948
|
+
builtInOAuthProfileFor(state)?.confidentialClientRequired &&
|
|
949
|
+
!isConfidentialOAuthClient(client)
|
|
950
|
+
) {
|
|
951
|
+
throw new OAuthCallbackStageError(
|
|
952
|
+
"client_lookup",
|
|
953
|
+
"invalid_client",
|
|
954
|
+
new Error("Gmail OAuth client is unavailable"),
|
|
955
|
+
);
|
|
956
|
+
}
|
|
536
957
|
const token = await deadline.run("token_exchange", (signal) =>
|
|
537
958
|
exchangeAuthorizationCode(settings, {
|
|
538
959
|
code: input.code!,
|
|
@@ -545,6 +966,27 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
545
966
|
signal,
|
|
546
967
|
}),
|
|
547
968
|
);
|
|
969
|
+
const profile = builtInOAuthProfileFor(state);
|
|
970
|
+
if (profile?.freshRefreshTokenRequired && !token.refreshToken) {
|
|
971
|
+
// Never splice a previous account's refresh token into a new grant.
|
|
972
|
+
// Google can omit refresh_token even after another consent prompt.
|
|
973
|
+
throw new OAuthCallbackStageError(
|
|
974
|
+
"token_exchange",
|
|
975
|
+
"offline_access_unavailable",
|
|
976
|
+
new Error("Google did not grant offline access. Start a new Gmail connection attempt."),
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
const scopes = grantedScopes(token.scopeText, state.authorizeScopes, profile);
|
|
980
|
+
if (
|
|
981
|
+
profile?.fullRequestedScopesRequired &&
|
|
982
|
+
profile.requestedScopes?.some((scope) => !scopes.includes(scope))
|
|
983
|
+
) {
|
|
984
|
+
throw new OAuthCallbackStageError(
|
|
985
|
+
"token_exchange",
|
|
986
|
+
"insufficient_scope",
|
|
987
|
+
new Error("Google did not grant the reviewed Gmail permissions."),
|
|
988
|
+
);
|
|
989
|
+
}
|
|
548
990
|
const verification = await verifyMcpToolsListNonFatal(
|
|
549
991
|
observability,
|
|
550
992
|
settings,
|
|
@@ -552,7 +994,6 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
552
994
|
token,
|
|
553
995
|
deadline,
|
|
554
996
|
);
|
|
555
|
-
const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
|
|
556
997
|
const credential = credentialBundle(token, state, client);
|
|
557
998
|
const metadata = {
|
|
558
999
|
resource: state.resource,
|
|
@@ -562,11 +1003,24 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
562
1003
|
tokenEndpoint: state.tokenEndpoint,
|
|
563
1004
|
clientId: client.clientId,
|
|
564
1005
|
clientRegistrationMethod: state.clientRegistrationMethod,
|
|
1006
|
+
oauthDiscovery: {
|
|
1007
|
+
mode: state.discoveryMode,
|
|
1008
|
+
resource: state.resource,
|
|
1009
|
+
issuer: state.issuer,
|
|
1010
|
+
...(state.discoveryMetadataSha256 ? { metadataSha256: state.discoveryMetadataSha256 } : {}),
|
|
1011
|
+
...(state.protectedResourceMetadataUrl
|
|
1012
|
+
? { protectedResourceMetadataUrl: state.protectedResourceMetadataUrl }
|
|
1013
|
+
: {}),
|
|
1014
|
+
...(state.authorizationServerMetadataUrl
|
|
1015
|
+
? { authorizationServerMetadataUrl: state.authorizationServerMetadataUrl }
|
|
1016
|
+
: {}),
|
|
1017
|
+
},
|
|
565
1018
|
mcpToolsVerification: verification.metadata,
|
|
1019
|
+
...(verification.providerIdentity ?? {}),
|
|
566
1020
|
...(verification.tools ? { mcpTools: verification.tools } : {}),
|
|
567
1021
|
};
|
|
568
1022
|
const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
569
|
-
const
|
|
1023
|
+
const persist = async (scopedDb: Database) => {
|
|
570
1024
|
await requireOAuthCallbackGrant(scopedDb, state!);
|
|
571
1025
|
return state!.connectionId
|
|
572
1026
|
? await updateConnection(scopedDb, {
|
|
@@ -577,7 +1031,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
577
1031
|
subjectId: ownerSubjectId,
|
|
578
1032
|
providerDomain: state.providerDomain,
|
|
579
1033
|
kind: "oauth2",
|
|
580
|
-
status: "active",
|
|
1034
|
+
status: verification.connectionStatus ?? "active",
|
|
581
1035
|
credentialEncrypted,
|
|
582
1036
|
grantedScopes: scopes,
|
|
583
1037
|
expiresAt: token.expiresAt,
|
|
@@ -590,13 +1044,55 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
590
1044
|
subjectId: ownerSubjectId,
|
|
591
1045
|
providerDomain: state.providerDomain,
|
|
592
1046
|
kind: "oauth2",
|
|
1047
|
+
status: verification.connectionStatus ?? "active",
|
|
593
1048
|
credentialEncrypted,
|
|
594
1049
|
grantedScopes: scopes,
|
|
595
1050
|
expiresAt: token.expiresAt,
|
|
596
1051
|
metadata,
|
|
597
1052
|
createdBySubjectId: state.subjectId,
|
|
598
1053
|
});
|
|
599
|
-
}
|
|
1054
|
+
};
|
|
1055
|
+
if (connectOperation) {
|
|
1056
|
+
await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
|
|
1057
|
+
finishOAuthAcquisition(
|
|
1058
|
+
scopedDb,
|
|
1059
|
+
state!,
|
|
1060
|
+
{
|
|
1061
|
+
...connectOperation!,
|
|
1062
|
+
authorize: (tx, _attempt, origin) =>
|
|
1063
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
1064
|
+
commit: async (tx, current) => {
|
|
1065
|
+
const connection = await persist(tx);
|
|
1066
|
+
if (!connection)
|
|
1067
|
+
throw new HTTPException(409, {
|
|
1068
|
+
message: "connection changed during OAuth reconnect",
|
|
1069
|
+
});
|
|
1070
|
+
return {
|
|
1071
|
+
...current,
|
|
1072
|
+
revision: current.revision + 1,
|
|
1073
|
+
state: "complete",
|
|
1074
|
+
completionRequirement: "connection",
|
|
1075
|
+
credentialsCommitted: true,
|
|
1076
|
+
nextAction: { type: "none" },
|
|
1077
|
+
account: {
|
|
1078
|
+
id: connection.id,
|
|
1079
|
+
version: connection.version,
|
|
1080
|
+
providerId: current.providerId,
|
|
1081
|
+
label: state!.providerDomain,
|
|
1082
|
+
ownership: current.ownership,
|
|
1083
|
+
status: connection.status === "needs_reauth" ? "auth_needed" : "connected",
|
|
1084
|
+
},
|
|
1085
|
+
};
|
|
1086
|
+
},
|
|
1087
|
+
},
|
|
1088
|
+
integrationKey,
|
|
1089
|
+
),
|
|
1090
|
+
);
|
|
1091
|
+
return { redirectTo: state.returnUrl!, exactReturn: true };
|
|
1092
|
+
}
|
|
1093
|
+
const connection = await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
|
|
1094
|
+
withOrganizationIntegrationAcquisition(scopedDb, state!, [integrationKey], persist),
|
|
1095
|
+
);
|
|
600
1096
|
if (!connection) {
|
|
601
1097
|
throw new HTTPException(409, {
|
|
602
1098
|
message: "connection changed during OAuth reconnect; start again",
|
|
@@ -606,29 +1102,90 @@ async function completeMcpOAuthCallbackWithinDeadline(
|
|
|
606
1102
|
// the enable connectionRef straight from the redirect, without a listConnections
|
|
607
1103
|
// round-trip that could fail (transient, or a grant lacking connections:read)
|
|
608
1104
|
// and leave the connection created but the capability un-enabled.
|
|
609
|
-
return {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}),
|
|
616
|
-
};
|
|
1105
|
+
return callbackStateResult(state, "success", {
|
|
1106
|
+
connectionId: connection.id,
|
|
1107
|
+
providerDomain: connection.providerDomain,
|
|
1108
|
+
ownership: state.ownership,
|
|
1109
|
+
...(verification.metadata.status === "failed" ? { verification: "failed" } : {}),
|
|
1110
|
+
});
|
|
617
1111
|
} catch (error) {
|
|
618
1112
|
const staged =
|
|
619
1113
|
error instanceof OAuthCallbackStageError
|
|
620
1114
|
? error
|
|
621
1115
|
: new OAuthCallbackStageError("persist", "persist_failed", error);
|
|
622
1116
|
logOAuthCallbackFailure(observability, staged, state);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1117
|
+
if (connectOperation && builtInOAuthProfileFor(state)?.key === "official-gmail") {
|
|
1118
|
+
// The external return URL stays exact. Its caller reads the durable
|
|
1119
|
+
// attempt result, which must not remain in flight after a rejected grant.
|
|
1120
|
+
try {
|
|
1121
|
+
await withDatabaseStatementTimeout(db, OAUTH_CALLBACK_DB_STATEMENT_TIMEOUT_MS, (tx) =>
|
|
1122
|
+
finishConnectOperation(tx, state!, {
|
|
1123
|
+
...connectOperation!,
|
|
1124
|
+
authorize: (locked, _attempt, origin) =>
|
|
1125
|
+
requireConnectOwnerAuthority(locked, state!, "connections:write", origin),
|
|
1126
|
+
commit: async (_locked, current) => ({
|
|
1127
|
+
...current,
|
|
1128
|
+
revision: current.revision + 1,
|
|
1129
|
+
state: "failed",
|
|
1130
|
+
nextAction: { type: "none" },
|
|
1131
|
+
error: {
|
|
1132
|
+
code: staged.reason,
|
|
1133
|
+
message:
|
|
1134
|
+
staged.reason === "offline_access_unavailable"
|
|
1135
|
+
? "Google did not grant offline access. Your previous connection is unchanged. Start a new Gmail connection attempt."
|
|
1136
|
+
: staged.reason === "insufficient_scope"
|
|
1137
|
+
? "Google did not grant all required Gmail permissions. Your previous connection is unchanged. Start a new connection attempt and approve the requested permissions."
|
|
1138
|
+
: "Gmail authorization could not be verified. Your previous connection is unchanged. Start a new connection attempt.",
|
|
1139
|
+
retryable: true,
|
|
1140
|
+
},
|
|
1141
|
+
}),
|
|
1142
|
+
}),
|
|
1143
|
+
);
|
|
1144
|
+
} catch (settlementError) {
|
|
1145
|
+
logOAuthCallbackFailure(
|
|
1146
|
+
observability,
|
|
1147
|
+
new OAuthCallbackStageError("persist", "persist_failed", settlementError),
|
|
1148
|
+
state,
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return callbackStateResult(state, "error", {
|
|
1153
|
+
stage: staged.stage,
|
|
1154
|
+
reason: staged.reason,
|
|
1155
|
+
});
|
|
629
1156
|
}
|
|
630
1157
|
}
|
|
631
1158
|
|
|
1159
|
+
function exactExternalReturnUrl(raw: string): string {
|
|
1160
|
+
if (!raw || raw.length > 4096 || /[\u0000-\u0020\u007f]/.test(raw))
|
|
1161
|
+
throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
|
|
1162
|
+
let url: URL;
|
|
1163
|
+
try {
|
|
1164
|
+
url = new URL(raw);
|
|
1165
|
+
} catch {
|
|
1166
|
+
throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
|
|
1167
|
+
}
|
|
1168
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password)
|
|
1169
|
+
throw new HTTPException(422, { message: "invalid external OAuth returnUrl" });
|
|
1170
|
+
return raw;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function callbackStateResult(
|
|
1174
|
+
state: OAuthStatePayload | null,
|
|
1175
|
+
status: Parameters<typeof callbackReturnPath>[1],
|
|
1176
|
+
details: Parameters<typeof callbackReturnPath>[2],
|
|
1177
|
+
): OAuthCallbackResult {
|
|
1178
|
+
if ((state?.externalContinuation || state?.connectAttemptId) && state.returnUrl)
|
|
1179
|
+
return { redirectTo: state.returnUrl, exactReturn: true };
|
|
1180
|
+
return {
|
|
1181
|
+
redirectTo: callbackReturnPath(
|
|
1182
|
+
state?.returnPath ?? INTEGRATIONS_FALLBACK_PATH,
|
|
1183
|
+
status,
|
|
1184
|
+
details,
|
|
1185
|
+
),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
632
1189
|
export function integrationBaseUrl(publicBaseUrl: string | undefined, requestUrl: string): string {
|
|
633
1190
|
return (publicBaseUrl ?? new URL(requestUrl).origin).replace(/\/+$/, "");
|
|
634
1191
|
}
|
|
@@ -644,113 +1201,283 @@ export function requireIntegrationsStateSecret(settings: Settings): string {
|
|
|
644
1201
|
}
|
|
645
1202
|
|
|
646
1203
|
async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise<void> {
|
|
647
|
-
|
|
648
|
-
if (
|
|
649
|
-
!grant ||
|
|
650
|
-
grant.accountId !== state.accountId ||
|
|
651
|
-
!hasPermission(grant.permissions, "connections:write")
|
|
652
|
-
) {
|
|
653
|
-
throw new HTTPException(403, {
|
|
654
|
-
message: "OAuth subject no longer has permission to write this workspace connection",
|
|
655
|
-
});
|
|
656
|
-
}
|
|
1204
|
+
await requireConnectOwnerAuthority(db, state);
|
|
657
1205
|
}
|
|
658
1206
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
): void {
|
|
665
|
-
if (!hostedSlackMcp) return;
|
|
666
|
-
if (payload.oauthClient) {
|
|
667
|
-
throw new HTTPException(422, {
|
|
668
|
-
message: "Slack OAuth client credentials are deployment-managed",
|
|
669
|
-
});
|
|
670
|
-
}
|
|
671
|
-
if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
|
|
672
|
-
throw new HTTPException(422, {
|
|
673
|
-
message: "Slack provider identity does not match slack.com",
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
|
|
677
|
-
throw new HTTPException(422, {
|
|
678
|
-
message: `Slack MCP OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
|
|
682
|
-
throw new HTTPException(503, {
|
|
683
|
-
message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
|
|
684
|
-
});
|
|
1207
|
+
/** The hosted-Slack profile's origin pins, kept exported for its tests. */
|
|
1208
|
+
export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
|
|
1209
|
+
const pins = builtInOAuthProfileByKey("hosted-slack-mcp").authorizationServer;
|
|
1210
|
+
if (pins) {
|
|
1211
|
+
assertAuthorizationServerPins(as, pins);
|
|
685
1212
|
}
|
|
686
1213
|
}
|
|
687
1214
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (
|
|
694
|
-
issuerOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN && origin !== SLACK_MCP_ORIGIN) ||
|
|
695
|
-
endpointOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN)
|
|
696
|
-
) {
|
|
697
|
-
throw new HTTPException(422, {
|
|
698
|
-
message: "Slack MCP authorization metadata did not remain bound to slack.com",
|
|
699
|
-
});
|
|
1215
|
+
/** The official-Gmail profile's origin pins, kept exported for its tests. */
|
|
1216
|
+
export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void {
|
|
1217
|
+
const pins = builtInOAuthProfileByKey("official-gmail").authorizationServer;
|
|
1218
|
+
if (pins) {
|
|
1219
|
+
assertAuthorizationServerPins(as, pins);
|
|
700
1220
|
}
|
|
701
1221
|
}
|
|
702
1222
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1223
|
+
/** Inspect without credentials, registration, or running tools. */
|
|
1224
|
+
export async function inspectMcpAuthentication(
|
|
1225
|
+
resource: string,
|
|
1226
|
+
settings: Settings,
|
|
1227
|
+
): Promise<{
|
|
1228
|
+
kind: "oauth2" | "none" | "unknown";
|
|
1229
|
+
message?: string;
|
|
1230
|
+
}> {
|
|
1231
|
+
const deadline = new OAuthStartDeadline(12_000);
|
|
1232
|
+
try {
|
|
1233
|
+
const response = await deadline.run("mcp_challenge", (signal) =>
|
|
1234
|
+
fetchOAuth(resource, settings, {
|
|
1235
|
+
method: "POST",
|
|
1236
|
+
headers: {
|
|
1237
|
+
accept: "application/json, text/event-stream",
|
|
1238
|
+
"content-type": "application/json",
|
|
1239
|
+
},
|
|
1240
|
+
body: JSON.stringify({
|
|
1241
|
+
jsonrpc: "2.0",
|
|
1242
|
+
id: "auth-discovery",
|
|
1243
|
+
method: "initialize",
|
|
1244
|
+
params: {
|
|
1245
|
+
protocolVersion: "2025-03-26",
|
|
1246
|
+
capabilities: {},
|
|
1247
|
+
clientInfo: { name: "OpenGeni", version: "1.0" },
|
|
1248
|
+
},
|
|
1249
|
+
}),
|
|
1250
|
+
signal,
|
|
1251
|
+
}),
|
|
1252
|
+
);
|
|
1253
|
+
const challenge = parseMcpOAuthChallenge(response.headers.get("www-authenticate"));
|
|
1254
|
+
let initialized = false;
|
|
1255
|
+
if (response.ok) {
|
|
1256
|
+
initialized = await deadline.run("mcp_challenge", async () => {
|
|
1257
|
+
const reader = response.body?.getReader();
|
|
1258
|
+
if (!reader) return false;
|
|
1259
|
+
const decoder = new TextDecoder();
|
|
1260
|
+
let text = "";
|
|
1261
|
+
let bytes = 0;
|
|
1262
|
+
const sse = response.headers.get("content-type")?.includes("text/event-stream");
|
|
1263
|
+
try {
|
|
1264
|
+
while (true) {
|
|
1265
|
+
const chunk = await reader.read();
|
|
1266
|
+
if (chunk.done) return false;
|
|
1267
|
+
bytes += chunk.value.byteLength;
|
|
1268
|
+
if (bytes > 262144) return false;
|
|
1269
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
1270
|
+
const messages = sse
|
|
1271
|
+
? text
|
|
1272
|
+
.split(/\r?\n/)
|
|
1273
|
+
.filter((line) => line.startsWith("data:"))
|
|
1274
|
+
.map((line) => line.slice(5).trim())
|
|
1275
|
+
: [text];
|
|
1276
|
+
for (const message of messages) {
|
|
1277
|
+
try {
|
|
1278
|
+
const value = JSON.parse(message);
|
|
1279
|
+
if (
|
|
1280
|
+
value.id === "auth-discovery" &&
|
|
1281
|
+
typeof value.result?.protocolVersion === "string" &&
|
|
1282
|
+
value.result?.serverInfo &&
|
|
1283
|
+
value.result?.capabilities
|
|
1284
|
+
)
|
|
1285
|
+
return true;
|
|
1286
|
+
} catch {
|
|
1287
|
+
/* Wait for a complete JSON message. */
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
} finally {
|
|
1292
|
+
await reader.cancel().catch(() => undefined);
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
} else {
|
|
1296
|
+
await cancelResponseBody(response);
|
|
1297
|
+
}
|
|
1298
|
+
let metadataRead = false;
|
|
1299
|
+
let metadataAbsent = true;
|
|
1300
|
+
try {
|
|
1301
|
+
await resolveMcpOAuthDiscovery({
|
|
1302
|
+
resourceUrl: resource,
|
|
1303
|
+
challenge,
|
|
1304
|
+
fetchMetadata: async ({ kind, url }) => {
|
|
1305
|
+
try {
|
|
1306
|
+
const result = await deadline.run(
|
|
1307
|
+
kind === "protected_resource"
|
|
1308
|
+
? "protected_resource_metadata"
|
|
1309
|
+
: "authorization_server_metadata",
|
|
1310
|
+
(signal) => fetchOAuthMetadata(url, settings, signal),
|
|
1311
|
+
);
|
|
1312
|
+
metadataRead = true;
|
|
1313
|
+
if (result.status !== "absent") metadataAbsent = false;
|
|
1314
|
+
return result;
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
metadataAbsent = false;
|
|
1317
|
+
throw error;
|
|
1318
|
+
}
|
|
1319
|
+
},
|
|
1320
|
+
validateEndpoint: (url, label) => oauthEndpointUrl(url, settings, label),
|
|
1321
|
+
canonicalizeResource: canonicalOAuthResource,
|
|
1322
|
+
});
|
|
1323
|
+
return { kind: "oauth2" };
|
|
1324
|
+
} catch (failure) {
|
|
1325
|
+
// Public initialization does not exclude optional or tool-level OAuth.
|
|
1326
|
+
// Only explicit absence of metadata permits the unauthenticated path.
|
|
1327
|
+
if (
|
|
1328
|
+
initialized &&
|
|
1329
|
+
!challenge.scheme &&
|
|
1330
|
+
!challenge.resourceMetadata &&
|
|
1331
|
+
metadataRead &&
|
|
1332
|
+
metadataAbsent
|
|
1333
|
+
)
|
|
1334
|
+
return { kind: "none" };
|
|
1335
|
+
return {
|
|
1336
|
+
kind: "unknown",
|
|
1337
|
+
message: mcpAuthInspectionFailureMessage(failure),
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
} catch (failure) {
|
|
1341
|
+
return {
|
|
1342
|
+
kind: "unknown",
|
|
1343
|
+
message: mcpAuthInspectionFailureMessage(failure),
|
|
1344
|
+
};
|
|
1345
|
+
} finally {
|
|
1346
|
+
deadline.dispose();
|
|
1347
|
+
}
|
|
707
1348
|
}
|
|
708
1349
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
) {
|
|
718
|
-
throw new HTTPException(422, {
|
|
719
|
-
message: "Gmail MCP authorization metadata did not remain bound to Google",
|
|
720
|
-
});
|
|
1350
|
+
function mcpAuthInspectionFailureMessage(failure: unknown): string {
|
|
1351
|
+
if (failure instanceof OAuthStartStageError) {
|
|
1352
|
+
if (failure.reason === "timeout") {
|
|
1353
|
+
return "Checking how to sign in timed out. Try again.";
|
|
1354
|
+
}
|
|
1355
|
+
if (failure.cause instanceof OAuthMetadataUpstreamError) {
|
|
1356
|
+
return `The provider returned HTTP ${failure.cause.upstreamStatus} while checking how to sign in. Retry or check the provider's setup instructions.`;
|
|
1357
|
+
}
|
|
721
1358
|
}
|
|
1359
|
+
return "Could not determine how to sign in. Retry or check the provider's setup instructions.";
|
|
722
1360
|
}
|
|
723
1361
|
|
|
724
1362
|
async function discoverMcpOAuth(
|
|
725
1363
|
resource: string,
|
|
726
1364
|
settings: Settings,
|
|
727
1365
|
deadline: OAuthStartDeadline,
|
|
1366
|
+
knownChallenge?: WwwAuthenticateChallenge,
|
|
728
1367
|
): Promise<{
|
|
729
1368
|
challenge: WwwAuthenticateChallenge;
|
|
730
1369
|
prm: ProtectedResourceMetadata;
|
|
731
1370
|
as: AuthorizationServerMetadata;
|
|
1371
|
+
mode: McpOAuthDiscoveryMode;
|
|
1372
|
+
resource: string;
|
|
1373
|
+
provenance: {
|
|
1374
|
+
protectedResourceMetadataUrl: string | null;
|
|
1375
|
+
authorizationServerMetadataUrl: string;
|
|
1376
|
+
metadataSha256: string;
|
|
1377
|
+
};
|
|
732
1378
|
}> {
|
|
733
|
-
const challenge =
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1379
|
+
const challenge =
|
|
1380
|
+
knownChallenge ??
|
|
1381
|
+
(await deadline.run("mcp_challenge", (signal) =>
|
|
1382
|
+
probeMcpChallenge(resource, settings, signal),
|
|
1383
|
+
));
|
|
1384
|
+
try {
|
|
1385
|
+
const discovery = await resolveMcpOAuthDiscovery({
|
|
1386
|
+
resourceUrl: resource,
|
|
1387
|
+
challenge,
|
|
1388
|
+
fetchMetadata: ({ kind, url }) =>
|
|
1389
|
+
deadline.run(
|
|
1390
|
+
kind === "protected_resource"
|
|
1391
|
+
? "protected_resource_metadata"
|
|
1392
|
+
: "authorization_server_metadata",
|
|
1393
|
+
(signal) => fetchOAuthMetadata(url, settings, signal),
|
|
1394
|
+
),
|
|
1395
|
+
validateEndpoint: (rawUrl, label) => oauthEndpointUrl(rawUrl, settings, label),
|
|
1396
|
+
canonicalizeResource: canonicalOAuthResource,
|
|
743
1397
|
});
|
|
1398
|
+
return {
|
|
1399
|
+
challenge: discovery.challenge,
|
|
1400
|
+
prm: discovery.protectedResourceMetadata,
|
|
1401
|
+
as: discovery.authorizationServerMetadata,
|
|
1402
|
+
mode: discovery.mode,
|
|
1403
|
+
resource: discovery.resource,
|
|
1404
|
+
provenance: discovery.provenance,
|
|
1405
|
+
};
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
if (error instanceof OAuthStartStageError) throw error;
|
|
1408
|
+
if (error instanceof McpOAuthDiscoveryError) {
|
|
1409
|
+
throw new OAuthStartStageError(
|
|
1410
|
+
error.stage,
|
|
1411
|
+
error.classification,
|
|
1412
|
+
new HTTPException(422, { message: error.message }),
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
throw error;
|
|
744
1416
|
}
|
|
745
|
-
|
|
746
|
-
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Exact built-in API bridge discovery never contacts its historical MCP identity. */
|
|
1420
|
+
async function discoverProviderOAuth(
|
|
1421
|
+
resource: string,
|
|
1422
|
+
settings: Settings,
|
|
1423
|
+
deadline: OAuthStartDeadline,
|
|
1424
|
+
profile: OAuthProviderProfile,
|
|
1425
|
+
) {
|
|
1426
|
+
const pinned = profile.providerOAuthDiscovery;
|
|
1427
|
+
if (!pinned || resource !== profile.requireExactMcpUrl?.url)
|
|
1428
|
+
throw new HTTPException(422, { message: "invalid provider OAuth resource" });
|
|
1429
|
+
const fetched = await deadline.run("authorization_server_metadata", (signal) =>
|
|
1430
|
+
fetchOAuthMetadata(pinned.metadataUrl, settings, signal),
|
|
747
1431
|
);
|
|
748
|
-
if (
|
|
749
|
-
throw new HTTPException(422, {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1432
|
+
if (fetched.status !== "present")
|
|
1433
|
+
throw new HTTPException(422, { message: "provider OAuth metadata is unavailable" });
|
|
1434
|
+
const raw = fetched.document;
|
|
1435
|
+
const issuer = normalizedIssuerKey(
|
|
1436
|
+
oauthEndpointUrl(requiredString(raw.issuer, "OAuth issuer"), settings, "OAuth issuer"),
|
|
1437
|
+
);
|
|
1438
|
+
if (issuer !== pinned.issuer)
|
|
1439
|
+
throw new HTTPException(422, { message: "provider OAuth issuer did not match its profile" });
|
|
1440
|
+
const as: AuthorizationServerMetadata = {
|
|
1441
|
+
issuer,
|
|
1442
|
+
authorizationServer: pinned.issuer,
|
|
1443
|
+
authorizationEndpoint: oauthEndpointUrl(
|
|
1444
|
+
requiredString(raw.authorization_endpoint, "OAuth authorization endpoint"),
|
|
1445
|
+
settings,
|
|
1446
|
+
"OAuth authorization endpoint",
|
|
1447
|
+
),
|
|
1448
|
+
tokenEndpoint: oauthEndpointUrl(
|
|
1449
|
+
requiredString(raw.token_endpoint, "OAuth token endpoint"),
|
|
1450
|
+
settings,
|
|
1451
|
+
"OAuth token endpoint",
|
|
1452
|
+
),
|
|
1453
|
+
clientIdMetadataDocumentSupported: false,
|
|
1454
|
+
tokenEndpointAuthMethodsSupported: stringArray(raw.token_endpoint_auth_methods_supported),
|
|
1455
|
+
codeChallengeMethodsSupported: stringArray(raw.code_challenge_methods_supported),
|
|
1456
|
+
raw,
|
|
1457
|
+
metadataUrl: fetched.url,
|
|
1458
|
+
};
|
|
1459
|
+
if (!as.codeChallengeMethodsSupported.includes("S256"))
|
|
1460
|
+
throw new HTTPException(422, { message: "provider OAuth requires PKCE S256" });
|
|
1461
|
+
return {
|
|
1462
|
+
challenge: { scheme: null, scope: [] } as WwwAuthenticateChallenge,
|
|
1463
|
+
prm: {
|
|
1464
|
+
resource,
|
|
1465
|
+
authorizationServers: [pinned.issuer],
|
|
1466
|
+
scopesSupported: [...(profile.requestedScopes ?? [])],
|
|
1467
|
+
raw: {},
|
|
1468
|
+
metadataUrl: "",
|
|
1469
|
+
} as ProtectedResourceMetadata,
|
|
1470
|
+
as,
|
|
1471
|
+
mode: "provider_oauth_metadata" as const,
|
|
1472
|
+
resource,
|
|
1473
|
+
provenance: {
|
|
1474
|
+
protectedResourceMetadataUrl: null,
|
|
1475
|
+
authorizationServerMetadataUrl: fetched.url,
|
|
1476
|
+
metadataSha256: createHash("sha256")
|
|
1477
|
+
.update(stableJson({ profile: profile.key, resource, metadataUrl: fetched.url, raw }))
|
|
1478
|
+
.digest("hex"),
|
|
1479
|
+
},
|
|
1480
|
+
};
|
|
754
1481
|
}
|
|
755
1482
|
|
|
756
1483
|
async function probeMcpChallenge(
|
|
@@ -765,115 +1492,45 @@ async function probeMcpChallenge(
|
|
|
765
1492
|
});
|
|
766
1493
|
try {
|
|
767
1494
|
if (response.status !== 401) {
|
|
768
|
-
return {};
|
|
1495
|
+
return { scheme: null, scope: [] };
|
|
769
1496
|
}
|
|
770
|
-
return
|
|
1497
|
+
return parseMcpOAuthChallenge(response.headers.get("www-authenticate"));
|
|
771
1498
|
} finally {
|
|
772
1499
|
await cancelResponseBody(response);
|
|
773
1500
|
}
|
|
774
1501
|
}
|
|
775
1502
|
|
|
776
|
-
async function
|
|
777
|
-
|
|
778
|
-
settings: Settings,
|
|
779
|
-
advertisedUrl?: string,
|
|
780
|
-
signal?: AbortSignal,
|
|
781
|
-
): Promise<ProtectedResourceMetadata> {
|
|
782
|
-
const candidates = uniqueStrings([
|
|
783
|
-
...(advertisedUrl ? [advertisedUrl] : []),
|
|
784
|
-
...wellKnownCandidates(resource, "oauth-protected-resource"),
|
|
785
|
-
]);
|
|
786
|
-
for (const candidate of candidates) {
|
|
787
|
-
const payload = await fetchJsonObject(candidate, settings, signal).catch((error) => {
|
|
788
|
-
if (error instanceof HTTPException) {
|
|
789
|
-
throw error;
|
|
790
|
-
}
|
|
791
|
-
return null;
|
|
792
|
-
});
|
|
793
|
-
if (!payload) {
|
|
794
|
-
continue;
|
|
795
|
-
}
|
|
796
|
-
const authorizationServers = stringArray(payload.authorization_servers);
|
|
797
|
-
if (authorizationServers.length === 0) {
|
|
798
|
-
continue;
|
|
799
|
-
}
|
|
800
|
-
return {
|
|
801
|
-
authorizationServers,
|
|
802
|
-
scopesSupported: stringArray(payload.scopes_supported),
|
|
803
|
-
raw: payload,
|
|
804
|
-
...(stringValue(payload.resource) ? { resource: stringValue(payload.resource)! } : {}),
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
throw new HTTPException(422, {
|
|
808
|
-
message: "could not discover MCP protected resource metadata",
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
async function discoverAuthorizationServerMetadata(
|
|
813
|
-
authorizationServer: string,
|
|
1503
|
+
async function fetchOAuthMetadata(
|
|
1504
|
+
url: string,
|
|
814
1505
|
settings: Settings,
|
|
815
1506
|
signal: AbortSignal,
|
|
816
|
-
): Promise<
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
"OAuth authorization server",
|
|
821
|
-
).replace(/\/+$/, "");
|
|
822
|
-
const candidates = uniqueStrings([
|
|
823
|
-
// Prefer the RFC metadata locations before probing the issuer itself. Some
|
|
824
|
-
// providers (including Linear) redirect their issuer root to a human docs
|
|
825
|
-
// page; following that redirect can leave discovery waiting on an unrelated
|
|
826
|
-
// streaming response even though the well-known metadata is immediately
|
|
827
|
-
// available.
|
|
828
|
-
...wellKnownCandidates(safeAuthorizationServer, "oauth-authorization-server"),
|
|
829
|
-
...wellKnownCandidates(safeAuthorizationServer, "openid-configuration"),
|
|
830
|
-
safeAuthorizationServer,
|
|
831
|
-
]);
|
|
832
|
-
for (const candidate of candidates) {
|
|
833
|
-
const payload = await fetchJsonObject(candidate, settings, signal).catch((error) => {
|
|
834
|
-
if (error instanceof HTTPException) {
|
|
835
|
-
throw error;
|
|
836
|
-
}
|
|
837
|
-
return null;
|
|
838
|
-
});
|
|
839
|
-
if (!payload) {
|
|
840
|
-
continue;
|
|
841
|
-
}
|
|
842
|
-
const authorizationEndpoint = stringValue(payload.authorization_endpoint);
|
|
843
|
-
const tokenEndpoint = stringValue(payload.token_endpoint);
|
|
844
|
-
if (!authorizationEndpoint || !tokenEndpoint) {
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
const safeAuthorizationEndpoint = oauthEndpointUrl(
|
|
848
|
-
authorizationEndpoint,
|
|
849
|
-
settings,
|
|
850
|
-
"OAuth authorization endpoint",
|
|
851
|
-
);
|
|
852
|
-
const safeTokenEndpoint = oauthEndpointUrl(tokenEndpoint, settings, "OAuth token endpoint");
|
|
853
|
-
const registrationEndpoint = stringValue(payload.registration_endpoint);
|
|
854
|
-
const issuer = oauthEndpointUrl(
|
|
855
|
-
stringValue(payload.issuer) ?? safeAuthorizationServer,
|
|
856
|
-
settings,
|
|
857
|
-
"OAuth issuer",
|
|
858
|
-
);
|
|
859
|
-
const safeRegistrationEndpoint = registrationEndpoint
|
|
860
|
-
? oauthEndpointUrl(registrationEndpoint, settings, "OAuth registration endpoint")
|
|
861
|
-
: undefined;
|
|
862
|
-
return {
|
|
863
|
-
issuer,
|
|
864
|
-
authorizationServer: safeAuthorizationServer,
|
|
865
|
-
authorizationEndpoint: safeAuthorizationEndpoint,
|
|
866
|
-
tokenEndpoint: safeTokenEndpoint,
|
|
867
|
-
clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
|
|
868
|
-
tokenEndpointAuthMethodsSupported: stringArray(payload.token_endpoint_auth_methods_supported),
|
|
869
|
-
codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
|
|
870
|
-
raw: payload,
|
|
871
|
-
...(safeRegistrationEndpoint ? { registrationEndpoint: safeRegistrationEndpoint } : {}),
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
throw new HTTPException(422, {
|
|
875
|
-
message: "could not discover OAuth authorization server metadata",
|
|
1507
|
+
): Promise<McpOAuthMetadataFetchResult> {
|
|
1508
|
+
const response = await fetchOAuth(url, settings, {
|
|
1509
|
+
headers: { accept: "application/json" },
|
|
1510
|
+
signal,
|
|
876
1511
|
});
|
|
1512
|
+
if (response.status === 404 || response.status === 410) {
|
|
1513
|
+
await cancelResponseBody(response);
|
|
1514
|
+
return { status: "absent", url, httpStatus: response.status };
|
|
1515
|
+
}
|
|
1516
|
+
if (!response.ok) {
|
|
1517
|
+
await cancelResponseBody(response);
|
|
1518
|
+
throw new OAuthMetadataUpstreamError(response.status);
|
|
1519
|
+
}
|
|
1520
|
+
const payload = await readResponseJsonBounded<unknown>(
|
|
1521
|
+
response,
|
|
1522
|
+
OAUTH_MAX_RESPONSE_BYTES,
|
|
1523
|
+
"OAuth metadata response",
|
|
1524
|
+
{ signal },
|
|
1525
|
+
);
|
|
1526
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1527
|
+
throw new Error("OAuth metadata response was not a JSON object");
|
|
1528
|
+
}
|
|
1529
|
+
return {
|
|
1530
|
+
status: "present",
|
|
1531
|
+
url,
|
|
1532
|
+
document: payload as Record<string, unknown>,
|
|
1533
|
+
};
|
|
877
1534
|
}
|
|
878
1535
|
|
|
879
1536
|
async function registerOAuthClient(
|
|
@@ -884,16 +1541,16 @@ async function registerOAuthClient(
|
|
|
884
1541
|
redirectUri: string,
|
|
885
1542
|
scopes: string[],
|
|
886
1543
|
manual: OAuthStartRequest["oauthClient"],
|
|
1544
|
+
profile: OAuthProviderProfile,
|
|
887
1545
|
signal: AbortSignal,
|
|
1546
|
+
acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
|
|
888
1547
|
): Promise<OAuthClientRegistration> {
|
|
889
1548
|
const operator = operatorClientForAs(settings, as);
|
|
890
1549
|
if (operator) {
|
|
891
1550
|
return operator;
|
|
892
1551
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
// so prefer the simultaneously advertised registration endpoint.
|
|
896
|
-
if (prefersDynamicClientRegistration(as)) {
|
|
1552
|
+
const selfRegistration = preferredOAuthSelfRegistration(as, profile.clientSource);
|
|
1553
|
+
if (selfRegistration === "dcr") {
|
|
897
1554
|
return await getOrCreateDynamicClientRegistration(
|
|
898
1555
|
db,
|
|
899
1556
|
settings,
|
|
@@ -901,9 +1558,10 @@ async function registerOAuthClient(
|
|
|
901
1558
|
redirectUri,
|
|
902
1559
|
scopes,
|
|
903
1560
|
signal,
|
|
1561
|
+
acquisition,
|
|
904
1562
|
);
|
|
905
1563
|
}
|
|
906
|
-
if (
|
|
1564
|
+
if (selfRegistration === "cimd") {
|
|
907
1565
|
return {
|
|
908
1566
|
method: "cimd",
|
|
909
1567
|
issuer: as.issuer,
|
|
@@ -925,13 +1583,33 @@ async function registerOAuthClient(
|
|
|
925
1583
|
),
|
|
926
1584
|
};
|
|
927
1585
|
}
|
|
928
|
-
return await getOrCreateDynamicClientRegistration(
|
|
1586
|
+
return await getOrCreateDynamicClientRegistration(
|
|
1587
|
+
db,
|
|
1588
|
+
settings,
|
|
1589
|
+
as,
|
|
1590
|
+
redirectUri,
|
|
1591
|
+
scopes,
|
|
1592
|
+
signal,
|
|
1593
|
+
acquisition,
|
|
1594
|
+
);
|
|
929
1595
|
}
|
|
930
1596
|
|
|
931
|
-
function
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1597
|
+
export function preferredOAuthSelfRegistration(
|
|
1598
|
+
as: Pick<
|
|
1599
|
+
AuthorizationServerMetadata,
|
|
1600
|
+
"clientIdMetadataDocumentSupported" | "registrationEndpoint"
|
|
1601
|
+
>,
|
|
1602
|
+
clientSource: OAuthProviderProfile["clientSource"],
|
|
1603
|
+
): "cimd" | "dcr" | null {
|
|
1604
|
+
if (clientSource === "dcr") return "dcr";
|
|
1605
|
+
if (clientSource === "cimd" && as.clientIdMetadataDocumentSupported) return "cimd";
|
|
1606
|
+
// DCR produces an authorization-server-issued client id and is therefore the
|
|
1607
|
+
// interoperable default whenever the server advertises both registration
|
|
1608
|
+
// mechanisms. A reviewed provider profile may explicitly force CIMD for a
|
|
1609
|
+
// server whose registration endpoint is unsuitable.
|
|
1610
|
+
if (clientSource !== "cimd" && as.registrationEndpoint) return "dcr";
|
|
1611
|
+
if (as.clientIdMetadataDocumentSupported) return "cimd";
|
|
1612
|
+
return null;
|
|
935
1613
|
}
|
|
936
1614
|
|
|
937
1615
|
async function getOrCreateDynamicClientRegistration(
|
|
@@ -941,6 +1619,7 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
941
1619
|
redirectUri: string,
|
|
942
1620
|
scopes: string[],
|
|
943
1621
|
signal: AbortSignal,
|
|
1622
|
+
acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
|
|
944
1623
|
): Promise<OAuthClientRegistration> {
|
|
945
1624
|
const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
946
1625
|
if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, as, redirectUri, scopes)) {
|
|
@@ -961,6 +1640,12 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
961
1640
|
message: "manual OAuth client credentials are required for this authorization server",
|
|
962
1641
|
});
|
|
963
1642
|
}
|
|
1643
|
+
await withOrganizationIntegrationAcquisition(
|
|
1644
|
+
db,
|
|
1645
|
+
acquisition,
|
|
1646
|
+
[acquisition.integrationKey ?? "custom:mcp"],
|
|
1647
|
+
async () => {},
|
|
1648
|
+
);
|
|
964
1649
|
const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes, signal);
|
|
965
1650
|
const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
|
|
966
1651
|
const storeInput = {
|
|
@@ -972,34 +1657,43 @@ async function getOrCreateDynamicClientRegistration(
|
|
|
972
1657
|
tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
|
|
973
1658
|
metadata: registrationMetadata(as, redirectUri, scopes),
|
|
974
1659
|
};
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1660
|
+
// Registration has already happened outside this transaction. Fence only the
|
|
1661
|
+
// durable client credential; a restriction racing DCR must not persist it.
|
|
1662
|
+
return withOrganizationIntegrationAcquisition(
|
|
1663
|
+
db,
|
|
1664
|
+
acquisition,
|
|
1665
|
+
[acquisition.integrationKey ?? "custom:mcp"],
|
|
1666
|
+
async (tx) => {
|
|
1667
|
+
if (storedClient) {
|
|
1668
|
+
const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
|
|
1669
|
+
...storeInput,
|
|
1670
|
+
expectedClientId: storedClient.clientId,
|
|
1671
|
+
});
|
|
1672
|
+
if (replaced?.clientId === dcr.clientId) {
|
|
1673
|
+
return dcr;
|
|
1674
|
+
}
|
|
1675
|
+
return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
|
|
1676
|
+
}
|
|
1677
|
+
const storedWinner = await storeIntegrationOAuthClient(tx, storeInput);
|
|
1678
|
+
if (storedWinner.clientId === dcr.clientId) {
|
|
1679
|
+
return dcr;
|
|
1680
|
+
}
|
|
1681
|
+
const winner = await loadIntegrationOAuthClient(tx, settings, as.issuer);
|
|
1682
|
+
if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
|
|
1683
|
+
return dcrRegistrationFromStored(winner);
|
|
1684
|
+
}
|
|
1685
|
+
if (winner) {
|
|
1686
|
+
const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
|
|
1687
|
+
...storeInput,
|
|
1688
|
+
expectedClientId: winner.clientId,
|
|
1689
|
+
});
|
|
1690
|
+
if (replaced?.clientId === dcr.clientId) {
|
|
1691
|
+
return dcr;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
|
|
1695
|
+
},
|
|
1696
|
+
);
|
|
1003
1697
|
}
|
|
1004
1698
|
|
|
1005
1699
|
function storedDcrClientSatisfiesPolicy(
|
|
@@ -1106,23 +1800,21 @@ function operatorClientEntryFor(
|
|
|
1106
1800
|
candidates: string[],
|
|
1107
1801
|
): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
|
|
1108
1802
|
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
tokenEndpointAuthMethod: "client_secret_post",
|
|
1125
|
-
};
|
|
1803
|
+
const candidateOrigins = candidates.flatMap((candidate) => {
|
|
1804
|
+
try {
|
|
1805
|
+
return [new URL(candidate).origin];
|
|
1806
|
+
} catch {
|
|
1807
|
+
return [];
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
for (const entry of DEPLOYMENT_MANAGED_CLIENTS) {
|
|
1811
|
+
if (!candidateOrigins.some((origin) => entry.issuerOrigins.includes(origin))) {
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
const resolved = entry.resolve(settings);
|
|
1815
|
+
if (resolved) {
|
|
1816
|
+
return resolved;
|
|
1817
|
+
}
|
|
1126
1818
|
}
|
|
1127
1819
|
const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
|
|
1128
1820
|
const exactKeys = uniqueStrings(
|
|
@@ -1142,6 +1834,33 @@ function operatorClientEntryFor(
|
|
|
1142
1834
|
return null;
|
|
1143
1835
|
}
|
|
1144
1836
|
|
|
1837
|
+
/** Secret-free readiness for the exact registered client used by Gmail setup. */
|
|
1838
|
+
export function gmailOAuthClientConfigured(settings: Settings): boolean {
|
|
1839
|
+
const entry = operatorClientEntryFor(settings, ["https://accounts.google.com"]);
|
|
1840
|
+
return Boolean(
|
|
1841
|
+
entry &&
|
|
1842
|
+
isConfidentialOAuthClient({
|
|
1843
|
+
...entry,
|
|
1844
|
+
tokenEndpointAuthMethod: tokenAuthMethod(
|
|
1845
|
+
entry.tokenEndpointAuthMethod,
|
|
1846
|
+
Boolean(entry.clientSecret),
|
|
1847
|
+
),
|
|
1848
|
+
}),
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function isConfidentialOAuthClient(client: {
|
|
1853
|
+
clientId: string;
|
|
1854
|
+
clientSecret?: string | undefined;
|
|
1855
|
+
tokenEndpointAuthMethod: string;
|
|
1856
|
+
}): boolean {
|
|
1857
|
+
return Boolean(
|
|
1858
|
+
client.clientId.trim() &&
|
|
1859
|
+
client.clientSecret?.trim() &&
|
|
1860
|
+
["client_secret_post", "client_secret_basic"].includes(client.tokenEndpointAuthMethod),
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1145
1864
|
function normalizedIssuerKey(value: string): string {
|
|
1146
1865
|
return value.replace(/\/+$/, "");
|
|
1147
1866
|
}
|
|
@@ -1210,7 +1929,7 @@ async function existingOAuthConnectionForStart(
|
|
|
1210
1929
|
subjectId: string;
|
|
1211
1930
|
providerDomain: string;
|
|
1212
1931
|
mcpUrl: string;
|
|
1213
|
-
|
|
1932
|
+
connectionSelection: OAuthProviderProfile["connectionSelection"];
|
|
1214
1933
|
exactMcpBinding: boolean;
|
|
1215
1934
|
connectionId?: string | undefined;
|
|
1216
1935
|
requestedOwnership?: ConnectionOwnership | undefined;
|
|
@@ -1247,7 +1966,7 @@ async function existingOAuthConnectionForStart(
|
|
|
1247
1966
|
connection.providerDomain === input.providerDomain &&
|
|
1248
1967
|
(!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl),
|
|
1249
1968
|
);
|
|
1250
|
-
if (input.
|
|
1969
|
+
if (input.connectionSelection === "canonical_personal") {
|
|
1251
1970
|
return selectCanonicalPersonalSlackConnection(matching);
|
|
1252
1971
|
}
|
|
1253
1972
|
return matching.find((connection) => connection.status === "active") ?? null;
|
|
@@ -1272,6 +1991,8 @@ export function buildAuthorizationUrl(input: {
|
|
|
1272
1991
|
verifier: string;
|
|
1273
1992
|
scopes: string[];
|
|
1274
1993
|
resourceParameterSupported: boolean;
|
|
1994
|
+
/** Profile-declared extra authorize parameters, applied last. */
|
|
1995
|
+
extraParams?: Readonly<Record<string, string>> | undefined;
|
|
1275
1996
|
}): string {
|
|
1276
1997
|
const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
|
|
1277
1998
|
const url = new URL(endpoint);
|
|
@@ -1280,23 +2001,63 @@ export function buildAuthorizationUrl(input: {
|
|
|
1280
2001
|
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
1281
2002
|
url.searchParams.set("state", input.state);
|
|
1282
2003
|
if (input.resourceParameterSupported) {
|
|
2004
|
+
// Suppressing RFC 8707 is a profile fact (Google's endpoints reject the
|
|
2005
|
+
// parameter); any provider-specific replacement parameters, such as
|
|
2006
|
+
// Google's offline-consent options, are that profile's
|
|
2007
|
+
// `extraAuthorizeParams` rather than behavior implied by the suppression.
|
|
1283
2008
|
url.searchParams.set("resource", input.resource);
|
|
1284
|
-
} else {
|
|
1285
|
-
// Google's OAuth endpoints do not implement RFC 8707's `resource`
|
|
1286
|
-
// parameter. Explicit offline consent is required to obtain the refresh
|
|
1287
|
-
// token used by the durable connection broker.
|
|
1288
|
-
url.searchParams.set("access_type", "offline");
|
|
1289
|
-
url.searchParams.set("include_granted_scopes", "true");
|
|
1290
|
-
url.searchParams.set("prompt", "consent");
|
|
1291
2009
|
}
|
|
1292
2010
|
url.searchParams.set("code_challenge_method", "S256");
|
|
1293
2011
|
url.searchParams.set("code_challenge", pkceChallenge(input.verifier));
|
|
1294
2012
|
if (input.scopes.length > 0) {
|
|
1295
2013
|
url.searchParams.set("scope", input.scopes.join(" "));
|
|
1296
2014
|
}
|
|
2015
|
+
for (const [name, value] of Object.entries(input.extraParams ?? {})) {
|
|
2016
|
+
// Defense in depth behind the schema/curation validation: an extra
|
|
2017
|
+
// parameter can never displace a security parameter the client already
|
|
2018
|
+
// set (state, PKCE, scope, resource, redirect_uri, ...).
|
|
2019
|
+
if (!url.searchParams.has(name)) {
|
|
2020
|
+
url.searchParams.set(name, value);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
1297
2023
|
return url.toString();
|
|
1298
2024
|
}
|
|
1299
2025
|
|
|
2026
|
+
async function resolveMcpOAuthState(
|
|
2027
|
+
db: Database,
|
|
2028
|
+
settings: Settings,
|
|
2029
|
+
state: string,
|
|
2030
|
+
deadline: OAuthCallbackDeadline,
|
|
2031
|
+
): Promise<OAuthStatePayload> {
|
|
2032
|
+
const reference = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
|
|
2033
|
+
string,
|
|
2034
|
+
unknown
|
|
2035
|
+
> | null;
|
|
2036
|
+
if (reference?.kind !== "mcp_oauth_reference") {
|
|
2037
|
+
// In-flight grants from the previous release remain valid for their
|
|
2038
|
+
// normal ten-minute lifetime during a rolling deployment.
|
|
2039
|
+
return readOAuthState(state, settings);
|
|
2040
|
+
}
|
|
2041
|
+
const accountId = requiredString(reference.accountId, "state.accountId");
|
|
2042
|
+
const workspaceId = requiredString(reference.workspaceId, "state.workspaceId");
|
|
2043
|
+
const id = requiredString(reference.id, "state.id");
|
|
2044
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
|
|
2045
|
+
throw new HTTPException(400, { message: "invalid OAuth state reference" });
|
|
2046
|
+
}
|
|
2047
|
+
const encrypted = await runCallbackDatabaseStage(deadline, "state_verify", db, (scopedDb) =>
|
|
2048
|
+
loadIntegrationOAuthPendingState(scopedDb, { accountId, workspaceId, id }),
|
|
2049
|
+
);
|
|
2050
|
+
if (!encrypted) {
|
|
2051
|
+
throw new HTTPException(400, { message: "invalid or expired OAuth state" });
|
|
2052
|
+
}
|
|
2053
|
+
const fullState = decryptEnvironmentValue(requireEnvironmentEncryption(settings), encrypted);
|
|
2054
|
+
const resolved = readOAuthState(fullState, settings);
|
|
2055
|
+
if (resolved.accountId !== accountId || resolved.workspaceId !== workspaceId) {
|
|
2056
|
+
throw new HTTPException(400, { message: "OAuth state reference mismatch" });
|
|
2057
|
+
}
|
|
2058
|
+
return resolved;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1300
2061
|
function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
1301
2062
|
const payload = readSignedState(state, requireIntegrationsStateSecret(settings)) as Record<
|
|
1302
2063
|
string,
|
|
@@ -1305,19 +2066,76 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
1305
2066
|
if (!payload) {
|
|
1306
2067
|
throw new HTTPException(400, { message: "invalid or expired OAuth state" });
|
|
1307
2068
|
}
|
|
2069
|
+
if (
|
|
2070
|
+
payload.integrationKey !== undefined &&
|
|
2071
|
+
payload.integrationKey !== "gmail" &&
|
|
2072
|
+
payload.integrationKey !== "slack-personal"
|
|
2073
|
+
) {
|
|
2074
|
+
throw new HTTPException(400, { message: "invalid OAuth integration identity" });
|
|
2075
|
+
}
|
|
1308
2076
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1309
2077
|
const iat = numberValue(payload.iat);
|
|
1310
2078
|
if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
|
|
1311
2079
|
throw new HTTPException(400, { message: "invalid or expired OAuth state" });
|
|
1312
2080
|
}
|
|
1313
2081
|
const resource = requiredString(payload.resource, "state.resource");
|
|
2082
|
+
const encodedDiscoveryMode = stringValue(payload.discoveryMode);
|
|
2083
|
+
const discoveryMode = discoveryModeValue(encodedDiscoveryMode);
|
|
2084
|
+
const discoveryMetadataSha256 = stringValue(payload.discoveryMetadataSha256);
|
|
2085
|
+
if (
|
|
2086
|
+
encodedDiscoveryMode &&
|
|
2087
|
+
(!discoveryMetadataSha256 || !/^[0-9a-f]{64}$/.test(discoveryMetadataSha256))
|
|
2088
|
+
) {
|
|
2089
|
+
throw new HTTPException(400, { message: "invalid OAuth discovery state" });
|
|
2090
|
+
}
|
|
2091
|
+
const protectedResourceMetadataUrl = stringValue(payload.protectedResourceMetadataUrl);
|
|
2092
|
+
const authorizationServerMetadataUrl = stringValue(payload.authorizationServerMetadataUrl);
|
|
2093
|
+
if (
|
|
2094
|
+
encodedDiscoveryMode &&
|
|
2095
|
+
(!authorizationServerMetadataUrl ||
|
|
2096
|
+
(discoveryMode === "rfc9728_protected_resource" && !protectedResourceMetadataUrl) ||
|
|
2097
|
+
(discoveryMode !== "rfc9728_protected_resource" && protectedResourceMetadataUrl))
|
|
2098
|
+
) {
|
|
2099
|
+
throw new HTTPException(400, { message: "invalid OAuth discovery provenance state" });
|
|
2100
|
+
}
|
|
2101
|
+
const resourceParameterSupported = payload.resourceParameterSupported !== false;
|
|
2102
|
+
if (discoveryMode === "legacy_2025_03_26_metadata" && resourceParameterSupported) {
|
|
2103
|
+
throw new HTTPException(400, { message: "invalid legacy OAuth resource parameter state" });
|
|
2104
|
+
}
|
|
2105
|
+
const ownership = connectionOwnership(payload.ownership);
|
|
2106
|
+
if (!ownership) {
|
|
2107
|
+
throw new HTTPException(400, {
|
|
2108
|
+
message: "missing or invalid OAuth ownership; restart connection setup",
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
1314
2111
|
const parsed = {
|
|
1315
2112
|
accountId: requiredString(payload.accountId, "state.accountId"),
|
|
1316
2113
|
workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
|
|
1317
2114
|
subjectId: requiredString(payload.subjectId, "state.subjectId"),
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
2115
|
+
...(stringValue(payload.encryptedExternalContinuation)
|
|
2116
|
+
? {
|
|
2117
|
+
externalContinuation: ExternalActorContinuation.parse(
|
|
2118
|
+
JSON.parse(
|
|
2119
|
+
decryptEnvironmentValue(
|
|
2120
|
+
requireEnvironmentEncryption(settings),
|
|
2121
|
+
requiredString(payload.encryptedExternalContinuation, "state.externalContinuation"),
|
|
2122
|
+
),
|
|
2123
|
+
),
|
|
2124
|
+
),
|
|
2125
|
+
}
|
|
2126
|
+
: {}),
|
|
2127
|
+
...(payload.returnUrl !== undefined
|
|
2128
|
+
? { returnUrl: exactExternalReturnUrl(requiredString(payload.returnUrl, "state.returnUrl")) }
|
|
2129
|
+
: {}),
|
|
2130
|
+
...(stringValue(payload.connectAttemptId)
|
|
2131
|
+
? { connectAttemptId: requiredString(payload.connectAttemptId, "state.connectAttemptId") }
|
|
2132
|
+
: {}),
|
|
2133
|
+
ownership,
|
|
2134
|
+
// Absent on a legacy state, which therefore cannot land a personal owner.
|
|
2135
|
+
personalOwnerVerified: personalOwnerVerifiedInState(payload),
|
|
2136
|
+
...(payload.integrationKey
|
|
2137
|
+
? { integrationKey: payload.integrationKey as "gmail" | "slack-personal" }
|
|
2138
|
+
: {}),
|
|
1321
2139
|
providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
|
|
1322
2140
|
mcpUrl: stringValue(payload.mcpUrl) ?? resource,
|
|
1323
2141
|
resource,
|
|
@@ -1343,11 +2161,31 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
1343
2161
|
settings,
|
|
1344
2162
|
"OAuth issuer",
|
|
1345
2163
|
),
|
|
2164
|
+
discoveryMode,
|
|
2165
|
+
...(discoveryMetadataSha256 ? { discoveryMetadataSha256 } : {}),
|
|
2166
|
+
...(protectedResourceMetadataUrl
|
|
2167
|
+
? {
|
|
2168
|
+
protectedResourceMetadataUrl: oauthEndpointUrl(
|
|
2169
|
+
protectedResourceMetadataUrl,
|
|
2170
|
+
settings,
|
|
2171
|
+
"OAuth protected resource metadata",
|
|
2172
|
+
),
|
|
2173
|
+
}
|
|
2174
|
+
: {}),
|
|
2175
|
+
...(authorizationServerMetadataUrl
|
|
2176
|
+
? {
|
|
2177
|
+
authorizationServerMetadataUrl: oauthEndpointUrl(
|
|
2178
|
+
authorizationServerMetadataUrl,
|
|
2179
|
+
settings,
|
|
2180
|
+
"OAuth authorization server metadata",
|
|
2181
|
+
),
|
|
2182
|
+
}
|
|
2183
|
+
: {}),
|
|
1346
2184
|
clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
|
|
1347
2185
|
tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
|
|
1348
2186
|
// States minted before provider-specific compatibility was introduced used
|
|
1349
2187
|
// the RFC 8707 resource parameter.
|
|
1350
|
-
resourceParameterSupported
|
|
2188
|
+
resourceParameterSupported,
|
|
1351
2189
|
...(stringValue(payload.encryptedClientSecret)
|
|
1352
2190
|
? { encryptedClientSecret: stringValue(payload.encryptedClientSecret)! }
|
|
1353
2191
|
: {}),
|
|
@@ -1357,16 +2195,72 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
|
|
|
1357
2195
|
};
|
|
1358
2196
|
const connectionId = stringValue(payload.connectionId);
|
|
1359
2197
|
const connectionVersion = numberValue(payload.connectionVersion);
|
|
2198
|
+
assertProfileOwnership(builtInOAuthProfileFor(parsed), parsed.ownership);
|
|
1360
2199
|
if (Boolean(connectionId) !== Boolean(connectionVersion)) {
|
|
1361
2200
|
throw new HTTPException(400, { message: "invalid OAuth reconnect state" });
|
|
1362
2201
|
}
|
|
2202
|
+
if (
|
|
2203
|
+
parsed.discoveryMode === "legacy_2025_03_26_metadata" &&
|
|
2204
|
+
(normalizedIssuerKey(parsed.authorizationServer) !== normalizedIssuerKey(parsed.issuer) ||
|
|
2205
|
+
new URL(parsed.resource).origin !== new URL(parsed.issuer).origin)
|
|
2206
|
+
) {
|
|
2207
|
+
throw new HTTPException(400, { message: "invalid OAuth discovery binding state" });
|
|
2208
|
+
}
|
|
2209
|
+
if (parsed.discoveryMode === "provider_oauth_metadata") {
|
|
2210
|
+
const profile = builtInOAuthProfileFor(parsed);
|
|
2211
|
+
const discovery = profile?.providerOAuthDiscovery;
|
|
2212
|
+
if (
|
|
2213
|
+
!discovery ||
|
|
2214
|
+
parsed.mcpUrl !== profile?.requireExactMcpUrl?.url ||
|
|
2215
|
+
parsed.resource !== parsed.mcpUrl ||
|
|
2216
|
+
parsed.providerDomain !== profile?.postDiscoveryProviderDomain?.domain ||
|
|
2217
|
+
normalizedIssuerKey(parsed.issuer) !== discovery.issuer ||
|
|
2218
|
+
parsed.authorizationServer !== discovery.issuer ||
|
|
2219
|
+
parsed.authorizationServerMetadataUrl !== discovery.metadataUrl ||
|
|
2220
|
+
parsed.resourceParameterSupported
|
|
2221
|
+
) {
|
|
2222
|
+
throw new HTTPException(400, { message: "invalid provider OAuth discovery binding state" });
|
|
2223
|
+
}
|
|
2224
|
+
assertDiscoveredAuthorizationServer(
|
|
2225
|
+
settings,
|
|
2226
|
+
{ ...parsed, authorizationEndpoint: discovery.issuer },
|
|
2227
|
+
profile!,
|
|
2228
|
+
parsed.providerDomain,
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
1363
2231
|
return {
|
|
1364
2232
|
...parsed,
|
|
2233
|
+
...(parsed.discoveryMode === "provider_oauth_metadata"
|
|
2234
|
+
? { issuer: normalizedIssuerKey(parsed.issuer) }
|
|
2235
|
+
: {}),
|
|
1365
2236
|
...(connectionId ? { connectionId } : {}),
|
|
1366
2237
|
...(connectionVersion !== undefined ? { connectionVersion } : {}),
|
|
1367
2238
|
};
|
|
1368
2239
|
}
|
|
1369
2240
|
|
|
2241
|
+
function discoveryModeValue(value: string | undefined): OAuthDiscoveryMode {
|
|
2242
|
+
if (!value) {
|
|
2243
|
+
// Every state minted before discovery modes existed used RFC 9728 PRM.
|
|
2244
|
+
return "rfc9728_protected_resource";
|
|
2245
|
+
}
|
|
2246
|
+
if (
|
|
2247
|
+
value === "rfc9728_protected_resource" ||
|
|
2248
|
+
value === "legacy_2025_03_26_metadata" ||
|
|
2249
|
+
value === "provider_oauth_metadata"
|
|
2250
|
+
) {
|
|
2251
|
+
return value;
|
|
2252
|
+
}
|
|
2253
|
+
throw new HTTPException(400, { message: "invalid OAuth discovery mode state" });
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
function assertProfileOwnership(
|
|
2257
|
+
profile: OAuthProviderProfile | null,
|
|
2258
|
+
ownership: ConnectionOwnership,
|
|
2259
|
+
): void {
|
|
2260
|
+
if (profile?.requiredOwnership && ownership !== profile.requiredOwnership.ownership)
|
|
2261
|
+
throw new HTTPException(422, { message: profile.requiredOwnership.message });
|
|
2262
|
+
}
|
|
2263
|
+
|
|
1370
2264
|
function connectionOwnership(value: unknown): ConnectionOwnership | undefined {
|
|
1371
2265
|
return value === "workspace" || value === "personal" ? value : undefined;
|
|
1372
2266
|
}
|
|
@@ -1562,6 +2456,9 @@ function logOAuthStartFailure(
|
|
|
1562
2456
|
function oauthStartFailureReason(error: unknown): string {
|
|
1563
2457
|
if (error instanceof RequestDeadlineError) return "timeout";
|
|
1564
2458
|
if (error instanceof DestinationPolicyError) return error.reason;
|
|
2459
|
+
if (error instanceof OAuthMetadataUpstreamError) {
|
|
2460
|
+
return `upstream_http_${error.upstreamStatus}`;
|
|
2461
|
+
}
|
|
1565
2462
|
if (error instanceof HTTPException) return `http_${error.status}`;
|
|
1566
2463
|
if (error instanceof SyntaxError) return "invalid_response";
|
|
1567
2464
|
return "request_failed";
|
|
@@ -1605,15 +2502,29 @@ function isDatabaseStatementTimeout(error: unknown): boolean {
|
|
|
1605
2502
|
|
|
1606
2503
|
function oauthStartApiError(error: OAuthStartStageError): ApiHttpError {
|
|
1607
2504
|
const timeout = error.reason === "timeout";
|
|
1608
|
-
const
|
|
2505
|
+
const metadataUpstream = error.cause instanceof OAuthMetadataUpstreamError ? error.cause : null;
|
|
2506
|
+
const status = timeout
|
|
2507
|
+
? 408
|
|
2508
|
+
: metadataUpstream
|
|
2509
|
+
? 502
|
|
2510
|
+
: error.cause instanceof HTTPException
|
|
2511
|
+
? error.cause.status
|
|
2512
|
+
: 422;
|
|
1609
2513
|
return new ApiHttpError(status, {
|
|
1610
2514
|
code: timeout || status >= 500 ? "upstream_unavailable" : "validation_failed",
|
|
1611
|
-
retryable:
|
|
2515
|
+
retryable:
|
|
2516
|
+
timeout ||
|
|
2517
|
+
status === 429 ||
|
|
2518
|
+
(metadataUpstream
|
|
2519
|
+
? metadataUpstream.upstreamStatus === 429 || metadataUpstream.upstreamStatus >= 500
|
|
2520
|
+
: status >= 500),
|
|
1612
2521
|
message: timeout
|
|
1613
2522
|
? oauthStartTimeoutMessage(error.stage)
|
|
1614
|
-
:
|
|
1615
|
-
? error.
|
|
1616
|
-
:
|
|
2523
|
+
: metadataUpstream
|
|
2524
|
+
? `OAuth provider returned HTTP ${metadataUpstream.upstreamStatus} during ${oauthStartStageLabel(error.stage)}.`
|
|
2525
|
+
: error.cause instanceof HTTPException
|
|
2526
|
+
? error.cause.message
|
|
2527
|
+
: `Connection setup failed during ${oauthStartStageLabel(error.stage)}.`,
|
|
1617
2528
|
details: {
|
|
1618
2529
|
oauthStage: error.stage,
|
|
1619
2530
|
oauthReason: error.reason,
|
|
@@ -1637,6 +2548,8 @@ function oauthStartStageLabel(stage: OAuthStartStage): string {
|
|
|
1637
2548
|
return "authorization-server discovery";
|
|
1638
2549
|
case "client_registration":
|
|
1639
2550
|
return "OAuth client registration";
|
|
2551
|
+
case "state_persist":
|
|
2552
|
+
return "OAuth state persistence";
|
|
1640
2553
|
}
|
|
1641
2554
|
}
|
|
1642
2555
|
|
|
@@ -1762,11 +2675,44 @@ async function verifyMcpToolsListNonFatal(
|
|
|
1762
2675
|
| { status: "ok"; checkedAt: string; toolCount: number }
|
|
1763
2676
|
| { status: "failed"; checkedAt: string; reason: string };
|
|
1764
2677
|
tools?: Array<{ name: string; description?: string }>;
|
|
2678
|
+
providerIdentity?: Record<string, string>;
|
|
2679
|
+
connectionStatus?: "needs_reauth";
|
|
1765
2680
|
}> {
|
|
2681
|
+
let grantRejected = false;
|
|
1766
2682
|
try {
|
|
1767
|
-
const
|
|
1768
|
-
|
|
1769
|
-
|
|
2683
|
+
const profile = builtInOAuthProfileFor(state);
|
|
2684
|
+
const local = profile?.localToolVerification;
|
|
2685
|
+
let providerIdentity: Record<string, string> | undefined;
|
|
2686
|
+
const tools = await deadline.run("tools_list", async (signal) => {
|
|
2687
|
+
if (!local || state.mcpUrl !== profile?.requireExactMcpUrl?.url) {
|
|
2688
|
+
return verifyMcpToolsList(settings, state.mcpUrl, token, signal);
|
|
2689
|
+
}
|
|
2690
|
+
const response = await fetchOAuth(local.url, settings, {
|
|
2691
|
+
method: local.method ?? "POST",
|
|
2692
|
+
signal,
|
|
2693
|
+
headers: {
|
|
2694
|
+
authorization: `${normalizeBearerScheme(token.tokenType)} ${token.accessToken}`,
|
|
2695
|
+
},
|
|
2696
|
+
});
|
|
2697
|
+
if (!response.ok) {
|
|
2698
|
+
await cancelResponseBody(response);
|
|
2699
|
+
throw new Error("Connector token verification failed");
|
|
2700
|
+
}
|
|
2701
|
+
const payload = await readResponseJsonBounded<Record<string, unknown>>(
|
|
2702
|
+
response,
|
|
2703
|
+
OAUTH_MAX_RESPONSE_BYTES,
|
|
2704
|
+
"Connector token verification",
|
|
2705
|
+
{ signal },
|
|
2706
|
+
);
|
|
2707
|
+
grantRejected = local.isRejectedGrant?.(payload) ?? false;
|
|
2708
|
+
providerIdentity = local.validateIdentity(payload);
|
|
2709
|
+
const verifiedTools = local.toolsForScopes(
|
|
2710
|
+
grantedScopes(token.scopeText, state.authorizeScopes, profile),
|
|
2711
|
+
);
|
|
2712
|
+
if (local.required && verifiedTools.length === 0)
|
|
2713
|
+
throw new Error("Connector verification did not report an authorized tool scope");
|
|
2714
|
+
return verifiedTools;
|
|
2715
|
+
});
|
|
1770
2716
|
return {
|
|
1771
2717
|
metadata: {
|
|
1772
2718
|
status: "ok",
|
|
@@ -1774,6 +2720,7 @@ async function verifyMcpToolsListNonFatal(
|
|
|
1774
2720
|
toolCount: tools.length,
|
|
1775
2721
|
},
|
|
1776
2722
|
tools,
|
|
2723
|
+
...(providerIdentity ? { providerIdentity } : {}),
|
|
1777
2724
|
};
|
|
1778
2725
|
} catch (error) {
|
|
1779
2726
|
const staged =
|
|
@@ -1783,6 +2730,7 @@ async function verifyMcpToolsListNonFatal(
|
|
|
1783
2730
|
if (staged.reason === "timeout" && deadline.signal.aborted) {
|
|
1784
2731
|
throw staged;
|
|
1785
2732
|
}
|
|
2733
|
+
if (builtInOAuthProfileFor(state)?.localToolVerification?.required) throw staged;
|
|
1786
2734
|
logOAuthVerificationWarning(observability, staged, state);
|
|
1787
2735
|
return {
|
|
1788
2736
|
metadata: {
|
|
@@ -1790,6 +2738,7 @@ async function verifyMcpToolsListNonFatal(
|
|
|
1790
2738
|
checkedAt: new Date().toISOString(),
|
|
1791
2739
|
reason: staged.reason,
|
|
1792
2740
|
},
|
|
2741
|
+
...(grantRejected ? { connectionStatus: "needs_reauth" as const } : {}),
|
|
1793
2742
|
};
|
|
1794
2743
|
}
|
|
1795
2744
|
}
|
|
@@ -1836,7 +2785,7 @@ function callbackReturnPath(
|
|
|
1836
2785
|
// Defense in depth: a `//host` pathname becomes a protocol-relative absolute
|
|
1837
2786
|
// Location — an open redirect from the unauthenticated callback.
|
|
1838
2787
|
if (url.pathname.startsWith("//")) {
|
|
1839
|
-
const fallback = new URL(
|
|
2788
|
+
const fallback = new URL(INTEGRATIONS_FALLBACK_PATH, "https://opengeni.local");
|
|
1840
2789
|
fallback.search = url.search;
|
|
1841
2790
|
return `${fallback.pathname}${fallback.search}`;
|
|
1842
2791
|
}
|
|
@@ -1892,48 +2841,6 @@ function oauthEndpointUrl(rawUrl: string, settings: Settings, label: string): st
|
|
|
1892
2841
|
}
|
|
1893
2842
|
}
|
|
1894
2843
|
|
|
1895
|
-
function safeReturnPath(value: string): string {
|
|
1896
|
-
if (!value.startsWith("/") || value.startsWith("//")) {
|
|
1897
|
-
throw new HTTPException(400, {
|
|
1898
|
-
message: "OAuth returnPath must be a relative path",
|
|
1899
|
-
});
|
|
1900
|
-
}
|
|
1901
|
-
const parsed = new URL(value, "https://opengeni.local");
|
|
1902
|
-
// `..` segments can normalize back into a `//host` prefix, which browsers
|
|
1903
|
-
// resolve as a protocol-relative absolute URL. Reject the NORMALIZED path.
|
|
1904
|
-
if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
|
|
1905
|
-
throw new HTTPException(400, {
|
|
1906
|
-
message: "OAuth returnPath must be a relative path",
|
|
1907
|
-
});
|
|
1908
|
-
}
|
|
1909
|
-
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
async function fetchJsonObject(
|
|
1913
|
-
url: string,
|
|
1914
|
-
settings: Settings,
|
|
1915
|
-
signal?: AbortSignal,
|
|
1916
|
-
): Promise<Record<string, unknown>> {
|
|
1917
|
-
const response = await fetchOAuth(url, settings, {
|
|
1918
|
-
headers: { accept: "application/json" },
|
|
1919
|
-
...(signal ? { signal } : {}),
|
|
1920
|
-
});
|
|
1921
|
-
if (!response.ok) {
|
|
1922
|
-
await cancelResponseBody(response);
|
|
1923
|
-
throw new Error(`HTTP ${response.status}`);
|
|
1924
|
-
}
|
|
1925
|
-
const payload = await readResponseJsonBounded<unknown>(
|
|
1926
|
-
response,
|
|
1927
|
-
OAUTH_MAX_RESPONSE_BYTES,
|
|
1928
|
-
"OAuth metadata response",
|
|
1929
|
-
{ ...(signal ? { signal } : {}) },
|
|
1930
|
-
);
|
|
1931
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1932
|
-
throw new Error("metadata response was not a JSON object");
|
|
1933
|
-
}
|
|
1934
|
-
return payload as Record<string, unknown>;
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
2844
|
async function fetchOAuth(
|
|
1938
2845
|
rawUrl: string,
|
|
1939
2846
|
settings: Settings,
|
|
@@ -2006,41 +2913,6 @@ async function cancelResponseBody(response: Response): Promise<void> {
|
|
|
2006
2913
|
await response.body?.cancel().catch(() => undefined);
|
|
2007
2914
|
}
|
|
2008
2915
|
|
|
2009
|
-
function parseWwwAuthenticate(header: string | null): WwwAuthenticateChallenge {
|
|
2010
|
-
if (!header) {
|
|
2011
|
-
return {};
|
|
2012
|
-
}
|
|
2013
|
-
const bearerIndex = header.toLowerCase().indexOf("bearer");
|
|
2014
|
-
if (bearerIndex < 0) {
|
|
2015
|
-
return {};
|
|
2016
|
-
}
|
|
2017
|
-
const paramsText = header.slice(bearerIndex + "bearer".length);
|
|
2018
|
-
const params: Record<string, string> = {};
|
|
2019
|
-
const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
|
|
2020
|
-
let match: RegExpExecArray | null;
|
|
2021
|
-
while ((match = re.exec(paramsText)) !== null) {
|
|
2022
|
-
const raw = match[2]!;
|
|
2023
|
-
params[match[1]!.toLowerCase()] = raw.startsWith('"')
|
|
2024
|
-
? raw.slice(1, -1).replace(/\\"/g, '"')
|
|
2025
|
-
: raw;
|
|
2026
|
-
}
|
|
2027
|
-
return {
|
|
2028
|
-
...(params.resource_metadata ? { resourceMetadata: params.resource_metadata } : {}),
|
|
2029
|
-
...(params.scope ? { scope: params.scope.split(/\s+/).filter(Boolean) } : {}),
|
|
2030
|
-
...(params.error ? { error: params.error } : {}),
|
|
2031
|
-
};
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
function wellKnownCandidates(rawUrl: string, name: string): string[] {
|
|
2035
|
-
const url = new URL(rawUrl);
|
|
2036
|
-
const path = url.pathname.replace(/^\/+|\/+$/g, "");
|
|
2037
|
-
return uniqueStrings([
|
|
2038
|
-
`${url.origin}/.well-known/${name}${path ? `/${path}` : ""}`,
|
|
2039
|
-
`${url.origin}${path ? `/${path}` : ""}/.well-known/${name}`,
|
|
2040
|
-
`${url.origin}/.well-known/${name}`,
|
|
2041
|
-
]);
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
2916
|
function chooseAuthorizeScopes(
|
|
2045
2917
|
requested: string[] | undefined,
|
|
2046
2918
|
challenged: string[] | undefined,
|
|
@@ -2060,13 +2932,38 @@ export function chooseMcpAuthorizeScopes(input: {
|
|
|
2060
2932
|
requested: string[] | undefined;
|
|
2061
2933
|
challenged: string[] | undefined;
|
|
2062
2934
|
supported: string[];
|
|
2935
|
+
authorizationServerScopesSupported?: string[];
|
|
2063
2936
|
}): string[] {
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
: chooseAuthorizeScopes(input.requested, input.challenged, input.supported);
|
|
2937
|
+
const profile = builtInOAuthProfileFor({ mcpUrl: input.mcpUrl }) ?? DEFAULT_OAUTH_PROFILE;
|
|
2938
|
+
return chooseProfileAuthorizeScopes(profile, input);
|
|
2067
2939
|
}
|
|
2068
2940
|
|
|
2069
|
-
function
|
|
2941
|
+
function chooseProfileAuthorizeScopes(
|
|
2942
|
+
profile: OAuthProviderProfile,
|
|
2943
|
+
input: {
|
|
2944
|
+
requested: string[] | undefined;
|
|
2945
|
+
challenged: string[] | undefined;
|
|
2946
|
+
supported: string[];
|
|
2947
|
+
authorizationServerScopesSupported?: string[];
|
|
2948
|
+
},
|
|
2949
|
+
): string[] {
|
|
2950
|
+
// Reviewed profiles keep their exact scope contract. A generic long-running
|
|
2951
|
+
// connector also needs the AS-advertised refresh grant: the MCP resource's
|
|
2952
|
+
// challenge often advertises only its resource permission (e.g. mcp:use).
|
|
2953
|
+
if (profile.requestedScopes) return [...profile.requestedScopes];
|
|
2954
|
+
const scopes = chooseAuthorizeScopes(input.requested, input.challenged, input.supported);
|
|
2955
|
+
return input.authorizationServerScopesSupported?.includes("offline_access")
|
|
2956
|
+
? uniqueStrings([...scopes, "offline_access"])
|
|
2957
|
+
: scopes;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
function grantedScopes(
|
|
2961
|
+
scopeText: string | undefined,
|
|
2962
|
+
fallback: string[],
|
|
2963
|
+
profile?: OAuthProviderProfile | null,
|
|
2964
|
+
): string[] {
|
|
2965
|
+
if (profile?.reportedScopesRequired && !scopeText) return [];
|
|
2966
|
+
if (profile?.normalizeGrantedScopes) return profile.normalizeGrantedScopes(scopeText ?? fallback);
|
|
2070
2967
|
if (scopeText) {
|
|
2071
2968
|
return uniqueStrings(scopeText.split(/\s+/).filter(Boolean));
|
|
2072
2969
|
}
|