Unified · Split
| @@ -14,18 +14,24 @@ import { · 13 unmodified lines | ||
| 14 | 14 | type AuthMethodSeed, |
| 15 | 15 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 16 | 16 | import { Badge } from "@executor-js/react/components/badge"; |
| 17 | +import { Label } from "@executor-js/react/components/label"; | |
| 18 | +import { Switch } from "@executor-js/react/components/switch"; | |
| 19 | +import { useEnterpriseIdentityProviderDescriptor } from "@executor-js/react/components/enterprise-idp-section"; | |
| 17 | 20 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 18 | 21 | |
| 19 | 22 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; |
| 20 | 23 | import type { |
| 21 | 24 | McpAuthMethod, |
| 22 | 25 | McpCanonicalAuthMethodInput, |
| 26 | + McpEnterpriseIdentityProvider, | |
| 23 | 27 | McpIntegrationConfig, |
| 24 | 28 | } from "../sdk/types"; |
| 25 | 29 | import { |
| 26 | 30 | editorValueFromMcpAuthMethod, |
| 27 | 31 | mcpAuthMethodInputFromEditorValue, |
| 32 | + mcpOAuthMethodInput, | |
| 28 | 33 | mcpWireAuthInput, |
| 34 | + sameEnterpriseIdentityProvider, | |
| 29 | 35 | } from "./auth-method-config"; |
| 30 | 36 | |
| 31 | 37 | type McpServer = { |
| @@ -95,16 +101,44 @@ function RemoteEdit(props: { · 63 unmodified lines | ||
| 95 | 101 | |
| 96 | 102 | const [error, setError] = useState<string | null>(null); |
| 97 | 103 | |
| 104 | + // Whether this server is declared enterprise-managed, and by which | |
| 105 | + // registration. Read off the stored oauth2 method rather than kept as a | |
| 106 | + // parallel flag: the declaration IS the state, and the toggle below only | |
| 107 | + // decides whether the organization's registration is attached to it. | |
| 108 | + const declaredProvider = useMemo<McpEnterpriseIdentityProvider | undefined>(() => { | |
| 109 | + for (const method of server.config.authenticationTemplate) { | |
| 110 | + if (method.kind === "oauth2" && method.enterpriseIdentityProvider !== undefined) { | |
| 111 | + return method.enterpriseIdentityProvider; | |
| 112 | + } | |
| 113 | + } | |
| 114 | + return undefined; | |
| 115 | + }, [server.config.authenticationTemplate]); | |
| 116 | + | |
| 117 | + const organizationProvider = useEnterpriseIdentityProviderDescriptor() ?? undefined; | |
| 118 | + const [managed, setManaged] = useState(declaredProvider !== undefined); | |
| 119 | + // The declaration this save would write: the organization's registration | |
| 120 | + // while the toggle is on, and — when the organization has since removed its | |
| 121 | + // registration — whatever the server already names, so an unrelated edit | |
| 122 | + // cannot quietly un-manage a live server. | |
| 123 | + const nextProvider: McpEnterpriseIdentityProvider | undefined = managed | |
| 124 | + ? (organizationProvider ?? declaredProvider) | |
| 125 | + : undefined; | |
| 126 | + | |
| 98 | 127 | // The edited methods, slugs preserved for seeded rows so existing |
| 99 | 128 | // connections (bound by template slug) stay attached. New rows omit the |
| 100 | − // slug — the backend assigns kind-based ones. | |
| 129 | + // slug — the backend assigns kind-based ones. Each row is reconciled against | |
| 130 | + // the method it was seeded from, because `configureMcpAuth` runs in `replace` | |
| 131 | + // mode here: anything the credential editor cannot express — the | |
| 132 | + // enterprise-managed declaration above all — has to be carried forward | |
| 133 | + // deliberately or it is erased by an unrelated edit. | |
| 101 | 134 | const editedMethods = useMemo<readonly McpCanonicalAuthMethodInput[]>( |
| 102 | 135 | () => |
| 103 | 136 | list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => { |
| 104 | − const input = mcpAuthMethodInputFromEditorValue(row.value); | |
| 105 | − return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input; | |
| 137 | + const edited = mcpAuthMethodInputFromEditorValue(row.value); | |
| 138 | + const declared = edited.kind === "oauth2" ? mcpOAuthMethodInput(nextProvider) : edited; | |
| 139 | + return row.seedSlug !== undefined ? { ...declared, slug: row.seedSlug } : declared; | |
| 106 | 140 | }), |
| 107 | − [list.rows], | |
| 141 | + [list.rows, nextProvider], | |
| 108 | 142 | ); |
| 109 | 143 | |
| 110 | 144 | const methodsChanged = useMemo(() => { |
| @@ -118,10 +152,25 @@ function RemoteEdit(props: { · 7 unmodified lines | ||
| 118 | 152 | if (method.kind === "apikey" && current.kind === "apikey") { |
| 119 | 153 | return !samePlacements(method.placements, current.placements); |
| 120 | 154 | } |
| 155 | + if (method.kind === "oauth2" && current.kind === "oauth2") { | |
| 156 | + return !sameEnterpriseIdentityProvider( | |
| 157 | + method.enterpriseIdentityProvider, | |
| 158 | + current.enterpriseIdentityProvider, | |
| 159 | + ); | |
| 160 | + } | |
| 121 | 161 | return false; |
| 122 | 162 | }); |
| 123 | 163 | }, [editedMethods, server.config.authenticationTemplate]); |
| 124 | 164 | |
| 165 | + const hasOAuthMethod = server.config.authenticationTemplate.some( | |
| 166 | + (method: McpAuthMethod) => method.kind === "oauth2", | |
| 167 | + ); | |
| 168 | + // Offered only where it can mean something: this server authenticates with | |
| 169 | + // OAuth, and either the organization has registered an identity provider or | |
| 170 | + // this server is already managed by one (so it can still be turned off). | |
| 171 | + const canDeclareManaged = | |
| 172 | + hasOAuthMethod && (organizationProvider !== undefined || declaredProvider !== undefined); | |
| 173 | + | |
| 125 | 174 | // Staged apply, run by the sheet's Save when the method list changed. |
| 126 | 175 | const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => { |
| 127 | 176 | setError(null); |
| @@ -168,6 +217,38 @@ function RemoteEdit(props: { · 40 unmodified lines | ||
| 168 | 217 | footerHint="Connections pick one of these methods. Removing a method detaches connections created against it." |
| 169 | 218 | /> |
| 170 | 219 | |
| 220 | + {/* Enterprise-Managed Authorization, per server. The organization's | |
| 221 | + identity provider is registered once (Organization settings); this is | |
| 222 | + where an administrator says WHICH servers authorize through it. | |
| 223 | + Declaring it can never take an ordinary server off the interactive | |
| 224 | + flow — the connect path still requires the server to advertise the | |
| 225 | + grant profile — so the copy promises a route, not an outcome. */} | |
| 226 | + {canDeclareManaged ? ( | |
| 227 | + <div className="flex items-start gap-3 rounded-md border border-border/60 px-3 py-3"> | |
| 228 | + <Switch | |
| 229 | + id="ema-managed-server" | |
| 230 | + checked={managed} | |
| 231 | + onCheckedChange={setManaged} | |
| 232 | + className="mt-0.5" | |
| 233 | + /> | |
| 234 | + <div className="min-w-0 space-y-1"> | |
| 235 | + <Label htmlFor="ema-managed-server" className="text-sm font-medium text-foreground"> | |
| 236 | + Managed by your organization | |
| 237 | + </Label> | |
| 238 | + <p className="text-xs text-muted-foreground"> | |
| 239 | + Members connect with their work identity instead of consenting to this server. Your | |
| 240 | + identity provider decides who gets access, and revokes it. | |
| 241 | + </p> | |
| 242 | + {organizationProvider === undefined ? ( | |
| 243 | + <p className="text-xs text-muted-foreground"> | |
| 244 | + Your organization no longer has an identity provider registered. This server keeps | |
| 245 | + the one it already names until you turn this off. | |
| 246 | + </p> | |
| 247 | + ) : null} | |
| 248 | + </div> | |
| 249 | + </div> | |
| 250 | + ) : null} | |
| 251 | + | |
| 171 | 252 | {error && <FormErrorAlert message={error} />} |
| 172 | 253 | </div> |
| 173 | 254 | ); |
| @@ -2,18 +2,14 @@ import { useMemo, useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | |
| 5 | −import { | |
| 6 | − AuthTemplateSlug, | |
| 7 | − IntegrationSlug, | |
| 8 | − type Connection, | |
| 9 | − type Owner, | |
| 10 | −} from "@executor-js/sdk/shared"; | |
| 5 | +import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared"; | |
| 11 | 6 | import { connectionsAllAtom } from "@executor-js/react/api/atoms"; |
| 12 | 7 | import { AddAccountModal } from "@executor-js/react/components/add-account-modal"; |
| 13 | 8 | import { OAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; |
| 14 | 9 | import type { AuthMethod } from "@executor-js/react/lib/auth-placements"; |
| 15 | 10 | |
| 16 | 11 | import { mcpServerAtom } from "./atoms"; |
| 12 | +import { authMethodsFromConfig } from "./auth-method-config"; | |
| 17 | 13 | import type { McpAuthMethod } from "../sdk/types"; |
| 18 | 14 | |
| 19 | 15 | // --------------------------------------------------------------------------- |
| @@ -47,21 +43,15 @@ export default function McpSignInButton(props: { integrationId: string; owner?: · 27 unmodified lines | ||
| 47 | 43 | (connection: Connection) => connection.integration === slug, |
| 48 | 44 | ); |
| 49 | 45 | |
| 46 | + // Projected through the plugin's ONE codec rather than assembled here: the | |
| 47 | + // server's enterprise-managed declaration rides on this method, and a | |
| 48 | + // hand-built copy silently dropped it — leaving this button offering | |
| 49 | + // per-server consent for a server the organization manages. | |
| 50 | 50 | const methods = useMemo<readonly AuthMethod[]>( |
| 51 | 51 | () => |
| 52 | 52 | remote === null || oauthMethod === null |
| 53 | 53 | ? [] |
| 54 | − : [ | |
| 55 | − { | |
| 56 | − id: oauthMethod.slug, | |
| 57 | − label: "OAuth", | |
| 58 | − kind: "oauth", | |
| 59 | − source: "spec", | |
| 60 | − template: AuthTemplateSlug.make(oauthMethod.slug), | |
| 61 | − placements: [], | |
| 62 | − oauth: { discoveryUrl: remote.endpoint, supportsDynamicRegistration: true }, | |
| 63 | − }, | |
| 64 | − ], | |
| 54 | + : authMethodsFromConfig([oauthMethod], remote.endpoint), | |
| 65 | 55 | [remote, oauthMethod], |
| 66 | 56 | ); |
| 67 | 57 | const initialState = useMemo( |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { OAuthClientSlug } from "@executor-js/sdk/shared"; | |
| 2 | 3 | import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor"; |
| 3 | 4 | |
| 4 | 5 | import { |
| @@ -6,6 +7,8 @@ import { · 1 unmodified line | ||
| 6 | 7 | editorValueFromMcpAuthMethod, |
| 7 | 8 | mcpAuthMethodInputFromEditorValue, |
| 8 | 9 | mcpAuthMethodInputsFromPlacements, |
| 10 | + mcpOAuthMethodInput, | |
| 11 | + sameEnterpriseIdentityProvider, | |
| 9 | 12 | } from "./auth-method-config"; |
| 10 | 13 | |
| 11 | 14 | describe("mcpAuthMethodInputFromEditorValue", () => { |
| @@ -175,6 +178,112 @@ describe("authMethodsFromConfig", () => { · 163 unmodified lines | ||
| 175 | 178 | }); |
| 176 | 179 | }); |
| 177 | 180 | |
| 181 | +// --------------------------------------------------------------------------- | |
| 182 | +// Enterprise-Managed Authorization: the per-server declaration. | |
| 183 | +// | |
| 184 | +// The declaration is a POINTER at the organization's identity-provider | |
| 185 | +// registration, and it is what puts a server on the work-identity route | |
| 186 | +// instead of per-server consent. Two things must hold or the feature silently | |
| 187 | +// stops working: it has to reach the console (a dropped pointer means the | |
| 188 | +// connect path offers ordinary consent for a managed server), and it must not | |
| 189 | +// be invented by any surface that cannot see the organization's registration. | |
| 190 | +// --------------------------------------------------------------------------- | |
| 191 | + | |
| 192 | +const PROVIDER = { | |
| 193 | + client: OAuthClientSlug.make("enterprise-identity-provider"), | |
| 194 | + clientOwner: "org", | |
| 195 | +} as const; | |
| 196 | + | |
| 197 | +describe("authMethodsFromConfig · enterprise-managed declaration", () => { | |
| 198 | + it("carries the server's identity-provider pointer onto the rendered method", () => { | |
| 199 | + const methods = authMethodsFromConfig( | |
| 200 | + [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }], | |
| 201 | + "https://mcp.example.com/mcp", | |
| 202 | + ); | |
| 203 | + expect(methods[0]?.oauth?.enterpriseIdentityProvider).toEqual(PROVIDER); | |
| 204 | + }); | |
| 205 | + | |
| 206 | + it("leaves the interactive route advertised beside it", () => { | |
| 207 | + // Declaring a provider asks the connect path to TRY the enterprise branch. | |
| 208 | + // Whether it is taken still depends on the server advertising the grant | |
| 209 | + // profile, so the ordinary route must remain available. | |
| 210 | + const methods = authMethodsFromConfig( | |
| 211 | + [{ slug: "oauth2", kind: "oauth2", enterpriseIdentityProvider: PROVIDER }], | |
| 212 | + "https://mcp.example.com/mcp", | |
| 213 | + ); | |
| 214 | + expect(methods[0]?.oauth?.supportsDynamicRegistration).toBe(true); | |
| 215 | + expect(methods[0]?.oauth?.discoveryUrl).toBe("https://mcp.example.com/mcp"); | |
| 216 | + }); | |
| 217 | + | |
| 218 | + it("declares nothing for an ordinary oauth2 server", () => { | |
| 219 | + const methods = authMethodsFromConfig( | |
| 220 | + [{ slug: "oauth2", kind: "oauth2" }], | |
| 221 | + "https://mcp.example.com/mcp", | |
| 222 | + ); | |
| 223 | + expect(methods[0]?.oauth?.enterpriseIdentityProvider).toBeUndefined(); | |
| 224 | + }); | |
| 225 | +}); | |
| 226 | + | |
| 227 | +describe("mcpOAuthMethodInput", () => { | |
| 228 | + it("attaches the organization's registration when the server is managed", () => { | |
| 229 | + expect(mcpOAuthMethodInput(PROVIDER)).toEqual({ | |
| 230 | + kind: "oauth2", | |
| 231 | + enterpriseIdentityProvider: PROVIDER, | |
| 232 | + }); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + it("omits the key entirely when it is not — never an explicit undefined", () => { | |
| 236 | + // `configureMcpAuth` decodes this against a union; an explicit | |
| 237 | + // `enterpriseIdentityProvider: undefined` is a different wire value and is | |
| 238 | + // rejected, so absence has to be real absence. | |
| 239 | + const input = mcpOAuthMethodInput(undefined); | |
| 240 | + expect(input).toEqual({ kind: "oauth2" }); | |
| 241 | + expect(Object.hasOwn(input, "enterpriseIdentityProvider")).toBe(false); | |
| 242 | + }); | |
| 243 | +}); | |
| 244 | + | |
| 245 | +describe("mcpAuthMethodInputFromEditorValue · enterprise-managed declaration", () => { | |
| 246 | + it("invents no declaration from the credential editor", () => { | |
| 247 | + // The editor edits credentials. Server policy is not one, and a surface | |
| 248 | + // that cannot see the organization's registration must not guess at it — | |
| 249 | + // the save path re-attaches it deliberately instead. | |
| 250 | + expect( | |
| 251 | + mcpAuthMethodInputFromEditorValue({ | |
| 252 | + kind: "oauth", | |
| 253 | + authorizationUrl: "", | |
| 254 | + tokenUrl: "", | |
| 255 | + scopes: [], | |
| 256 | + }), | |
| 257 | + ).toEqual({ kind: "oauth2" }); | |
| 258 | + }); | |
| 259 | +}); | |
| 260 | + | |
| 261 | +describe("sameEnterpriseIdentityProvider", () => { | |
| 262 | + it("compares the pointer by value, not by reference", () => { | |
| 263 | + // The stored copy is decoded from JSON, so it is never the same object as | |
| 264 | + // the one the console just built; a reference check would report every | |
| 265 | + // save as a change. | |
| 266 | + expect(sameEnterpriseIdentityProvider({ ...PROVIDER }, { ...PROVIDER })).toBe(true); | |
| 267 | + }); | |
| 268 | + | |
| 269 | + it("distinguishes a different registration", () => { | |
| 270 | + expect(sameEnterpriseIdentityProvider(PROVIDER, { ...PROVIDER, clientOwner: "user" })).toBe( | |
| 271 | + false, | |
| 272 | + ); | |
| 273 | + expect( | |
| 274 | + sameEnterpriseIdentityProvider(PROVIDER, { | |
| 275 | + ...PROVIDER, | |
| 276 | + client: OAuthClientSlug.make("other"), | |
| 277 | + }), | |
| 278 | + ).toBe(false); | |
| 279 | + }); | |
| 280 | + | |
| 281 | + it("treats declaring and not declaring as different", () => { | |
| 282 | + expect(sameEnterpriseIdentityProvider(PROVIDER, undefined)).toBe(false); | |
| 283 | + expect(sameEnterpriseIdentityProvider(undefined, undefined)).toBe(true); | |
| 284 | + }); | |
| 285 | +}); | |
| 286 | + | |
| 178 | 287 | describe("mcpAuthMethodInputsFromPlacements", () => { |
| 179 | 288 | it("builds ONE method carrying every named placement", () => { |
| 180 | 289 | expect( |
| @@ -21,6 +21,8 @@ import type { · 20 unmodified lines | ||
| 21 | 21 | McpAuthMethod, |
| 22 | 22 | McpAuthMethodInput, |
| 23 | 23 | McpCanonicalAuthMethodInput, |
| 24 | + McpEnterpriseIdentityProvider, | |
| 25 | + McpOAuthMethod, | |
| 24 | 26 | McpStdioEnvMethod, |
| 25 | 27 | } from "../sdk/types"; |
| 26 | 28 | |
| @@ -48,20 +50,37 @@ export const mcpWireAuthInput = ( · 21 unmodified lines | ||
| 48 | 50 | method: McpAuthMethod | McpCanonicalAuthMethodInput, |
| 49 | 51 | ): McpAuthMethodInput => wireAuthInputFromShared(method) as McpAuthMethodInput; |
| 50 | 52 | |
| 51 | −const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ | |
| 52 | − id: slug, | |
| 53 | +const oauthAuthMethod = (method: McpOAuthMethod, endpoint: string): AuthMethod => ({ | |
| 54 | + id: method.slug, | |
| 53 | 55 | label: "OAuth", |
| 54 | 56 | kind: "oauth", |
| 55 | − source: slug.startsWith("custom_") ? "custom" : "spec", | |
| 56 | − template: AuthTemplateSlug.make(slug), | |
| 57 | + source: method.slug.startsWith("custom_") ? "custom" : "spec", | |
| 58 | + template: AuthTemplateSlug.make(method.slug), | |
| 57 | 59 | placements: [], |
| 58 | − oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true }, | |
| 60 | + oauth: { | |
| 61 | + discoveryUrl: endpoint, | |
| 62 | + supportsDynamicRegistration: true, | |
| 63 | + // The server's own enterprise-managed declaration, carried onto the | |
| 64 | + // presentational method. `supportsDynamicRegistration` stays true beside | |
| 65 | + // it on purpose: declaring an identity provider is an ADDITIONAL route, not | |
| 66 | + // a replacement, and a server that turns out not to advertise the ID-JAG | |
| 67 | + // profile must still be connectable the ordinary way. | |
| 68 | + ...(method.enterpriseIdentityProvider === undefined | |
| 69 | + ? {} | |
| 70 | + : { enterpriseIdentityProvider: method.enterpriseIdentityProvider }), | |
| 71 | + }, | |
| 59 | 72 | }); |
| 60 | 73 | |
| 61 | 74 | /** Convert a generic editor value into one MCP auth-method input (no slug — |
| 62 | 75 | * the backend assigns carrier-derived slugs). An apikey value keeps every |
| 63 | 76 | * named placement (headers and query params mix freely); one with no usable |
| 64 | − * placement falls back to `none`. */ | |
| 77 | + * placement falls back to `none`. | |
| 78 | + * | |
| 79 | + * Deliberately carries NO enterprise-managed declaration: that is server | |
| 80 | + * policy, not a credential, so it has no editor field to come from. The | |
| 81 | + * surface that saves a managed server (`EditMcpIntegration`) re-attaches it | |
| 82 | + * explicitly from its own toggle, which is what keeps `configureMcpAuth`'s | |
| 83 | + * `replace` mode from erasing it on an unrelated edit. */ | |
| 65 | 84 | export function mcpAuthMethodInputFromEditorValue( |
| 66 | 85 | value: AuthTemplateEditorValue, |
| 67 | 86 | ): McpCanonicalAuthMethodInput { |
| @@ -71,6 +90,31 @@ export function mcpAuthMethodInputFromEditorValue( · 3 unmodified lines | ||
| 71 | 90 | }) as McpCanonicalAuthMethodInput; |
| 72 | 91 | } |
| 73 | 92 | |
| 93 | +/** One oauth2 method input carrying the organization's declaration, or none. | |
| 94 | + * | |
| 95 | + * The single place an enterprise-managed pointer is attached to a method on | |
| 96 | + * the way out. Written as a constructor rather than a spread at each call site | |
| 97 | + * so the absent case stays genuinely ABSENT: an explicit | |
| 98 | + * `enterpriseIdentityProvider: undefined` is a different value on the wire, | |
| 99 | + * and the union that decodes it rejects it. */ | |
| 100 | +export const mcpOAuthMethodInput = ( | |
| 101 | + provider: McpEnterpriseIdentityProvider | undefined, | |
| 102 | +): McpCanonicalAuthMethodInput => | |
| 103 | + provider === undefined | |
| 104 | + ? { kind: "oauth2" } | |
| 105 | + : { kind: "oauth2", enterpriseIdentityProvider: provider }; | |
| 106 | + | |
| 107 | +/** Whether two enterprise-managed declarations name the same registration. | |
| 108 | + * Compared field-by-field because the pointer travels through JSON and a | |
| 109 | + * decoded copy is never reference-equal to the stored one. */ | |
| 110 | +export const sameEnterpriseIdentityProvider = ( | |
| 111 | + a: McpEnterpriseIdentityProvider | undefined, | |
| 112 | + b: McpEnterpriseIdentityProvider | undefined, | |
| 113 | +): boolean => | |
| 114 | + a === undefined || b === undefined | |
| 115 | + ? a === b | |
| 116 | + : String(a.client) === String(b.client) && a.clientOwner === b.clientOwner; | |
| 117 | + | |
| 74 | 118 | /** Convert one stored MCP method into the generic editor value. */ |
| 75 | 119 | export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplateEditorValue { |
| 76 | 120 | if (method.kind === "oauth2") { |
| @@ -89,7 +133,7 @@ export function authMethodsFromConfig( · 12 unmodified lines | ||
| 89 | 133 | endpoint: string, |
| 90 | 134 | ): AuthMethod[] { |
| 91 | 135 | return methods.map((method: McpAuthMethod): AuthMethod => { |
| 92 | − if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint); | |
| 136 | + if (method.kind === "oauth2") return oauthAuthMethod(method, endpoint); | |
| 93 | 137 | if (method.kind === "stdio_env") return stdioEnvAuthMethod(method); |
| 94 | 138 | return authMethodFromSharedTemplate(method); |
| 95 | 139 | }); |
| @@ -4,6 +4,7 @@ import { OAuthClientSlug, type OAuthClientSummary } from "@executor-js/sdk/share · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER, |
| 6 | 6 | ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG, |
| 7 | + ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR, | |
| 7 | 8 | canSubmitEnterpriseIdentityProvider, |
| 8 | 9 | enterpriseIdentityProviderPayload, |
| 9 | 10 | findEnterpriseIdentityProvider, |
| @@ -42,14 +43,20 @@ describe("findEnterpriseIdentityProvider", () => { · 32 unmodified lines | ||
| 42 | 43 | }); |
| 43 | 44 | |
| 44 | 45 | describe("canSubmitEnterpriseIdentityProvider", () => { |
| 45 | − const valid = { submitting: false, tokenUrl: "https://idp.example/token", clientId: "abc" }; | |
| 46 | + const valid = { | |
| 47 | + submitting: false, | |
| 48 | + issuerUrl: "https://idp.example/oauth2/default", | |
| 49 | + clientId: "abc", | |
| 50 | + }; | |
| 46 | 51 | |
| 47 | 52 | it("accepts a public client with no secret", () => { |
| 48 | 53 | expect(canSubmitEnterpriseIdentityProvider(valid)).toBe(true); |
| 49 | 54 | }); |
| 50 | 55 | |
| 51 | − it("requires the token endpoint the assertion exchange is POSTed to", () => { | |
| 52 | − expect(canSubmitEnterpriseIdentityProvider({ ...valid, tokenUrl: " " })).toBe(false); | |
| 56 | + it("requires the issuer the endpoints are discovered from", () => { | |
| 57 | + // Endpoints are never typed: they are probed off the issuer, so the issuer | |
| 58 | + // is the field the registration cannot proceed without. | |
| 59 | + expect(canSubmitEnterpriseIdentityProvider({ ...valid, issuerUrl: " " })).toBe(false); | |
| 53 | 60 | }); |
| 54 | 61 | |
| 55 | 62 | it("requires the client id the exchange authenticates as", () => { |
| @@ -62,9 +69,14 @@ describe("canSubmitEnterpriseIdentityProvider", () => { · 6 unmodified lines | ||
| 62 | 69 | }); |
| 63 | 70 | |
| 64 | 71 | describe("enterpriseIdentityProviderPayload", () => { |
| 72 | + const discovered = { | |
| 73 | + authorizationUrl: " https://idp.example/authorize ", | |
| 74 | + tokenUrl: " https://idp.example/token ", | |
| 75 | + }; | |
| 76 | + | |
| 65 | 77 | it("registers the workspace-owned app under the reserved slug", () => { |
| 66 | 78 | const payload = enterpriseIdentityProviderPayload({ |
| 67 | − tokenUrl: " https://idp.example/token ", | |
| 79 | + ...discovered, | |
| 68 | 80 | clientId: " abc ", |
| 69 | 81 | clientSecret: " shh ", |
| 70 | 82 | }); |
| @@ -75,26 +87,45 @@ describe("enterpriseIdentityProviderPayload", () => { · 4 unmodified lines | ||
| 75 | 87 | expect(payload.clientSecret).toBe("shh"); |
| 76 | 88 | }); |
| 77 | 89 | |
| 78 | − it("records no authorization URL, because this app never runs a redirect", () => { | |
| 79 | − // The enterprise-managed chain exchanges an assertion the member already | |
| 80 | − // holds at the token endpoint; there is no authorization endpoint to store, | |
| 81 | − // and inventing one would claim a flow this registration does not run. | |
| 90 | + it("records the authorization endpoint the work-identity link needs", () => { | |
| 91 | + // The ID-JAG exchange itself only touches the token endpoint. The | |
| 92 | + // authorization endpoint is here for the hop that produces the subject | |
| 93 | + // token in the first place: an assertion issued BY this provider and | |
| 94 | + // audienced to THIS client, which a brokered login does not yield. | |
| 95 | + expect( | |
| 96 | + enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" }) | |
| 97 | + .authorizationUrl, | |
| 98 | + ).toBe("https://idp.example/authorize"); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it("registers the authorization-code grant, not the enterprise grant", () => { | |
| 102 | + // `id_jag` names the registration at an MCP server's authorization server. | |
| 103 | + // THIS app is the registration at the identity provider — it authenticates | |
| 104 | + // the exchange and runs the work-identity hop, both authorization-code | |
| 105 | + // shaped. | |
| 82 | 106 | expect( |
| 83 | − enterpriseIdentityProviderPayload({ | |
| 84 | − tokenUrl: "https://idp.example/token", | |
| 85 | − clientId: "abc", | |
| 86 | − clientSecret: "", | |
| 87 | − }).authorizationUrl, | |
| 88 | − ).toBe(""); | |
| 107 | + enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" }).grant, | |
| 108 | + ).toBe("authorization_code"); | |
| 89 | 109 | }); |
| 90 | 110 | |
| 91 | 111 | it("stamps no originating integration — it backs every managed server", () => { |
| 92 | 112 | expect( |
| 93 | − enterpriseIdentityProviderPayload({ | |
| 94 | − tokenUrl: "https://idp.example/token", | |
| 95 | − clientId: "abc", | |
| 96 | − clientSecret: "", | |
| 97 | − }).originIntegration, | |
| 113 | + enterpriseIdentityProviderPayload({ ...discovered, clientId: "abc", clientSecret: "" }) | |
| 114 | + .originIntegration, | |
| 98 | 115 | ).toBeNull(); |
| 99 | 116 | }); |
| 100 | 117 | }); |
| 118 | + | |
| 119 | +describe("ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR", () => { | |
| 120 | + it("names the same registration the section writes", () => { | |
| 121 | + // A server declaration and a connect request both point at the provider by | |
| 122 | + // (owner, slug). If this descriptor and the payload ever disagreed, every | |
| 123 | + // managed server would name a registration that does not exist. | |
| 124 | + expect(String(ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR.client)).toBe( | |
| 125 | + String(ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG), | |
| 126 | + ); | |
| 127 | + expect(ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR.clientOwner).toBe( | |
| 128 | + ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER, | |
| 129 | + ); | |
| 130 | + }); | |
| 131 | +}); | |
| @@ -2,12 +2,18 @@ import { useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | import * as Exit from "effect/Exit"; |
| 5 | −import { OAuthClientSlug, type OAuthClientSummary, type Owner } from "@executor-js/sdk/shared"; | |
| 5 | +import { | |
| 6 | + OAuthClientSlug, | |
| 7 | + type EnterpriseIdentityProviderDescriptor, | |
| 8 | + type OAuthClientSummary, | |
| 9 | + type Owner, | |
| 10 | +} from "@executor-js/sdk/shared"; | |
| 6 | 11 | import { toast } from "sonner"; |
| 7 | 12 | |
| 8 | 13 | import { |
| 9 | 14 | createOAuthClientOptimistic, |
| 10 | 15 | oauthClientsOptimisticAtom, |
| 16 | + probeOAuth, | |
| 11 | 17 | removeOAuthClientOptimistic, |
| 12 | 18 | } from "../api/atoms"; |
| 13 | 19 | import { trackEvent } from "../api/analytics"; |
| @@ -75,31 +81,43 @@ export const findEnterpriseIdentityProvider = ( · 61 unmodified lines | ||
| 75 | 81 | String(client.slug) === String(ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG), |
| 76 | 82 | ) ?? null; |
| 77 | 83 | |
| 78 | −/** Registering the provider needs the token endpoint the RFC 8693 exchange is | |
| 79 | − * POSTed to and the client id it authenticates as. The secret is optional: an | |
| 80 | − * IdP may treat the client as public. */ | |
| 84 | +/** The descriptor a managed server points at — the shape an MCP server's | |
| 85 | + * `enterpriseIdentityProvider` declaration and `oauth.start`'s `enterprise` | |
| 86 | + * input both speak. Derived from the reserved identity, never typed by hand. */ | |
| 87 | +export const ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR: EnterpriseIdentityProviderDescriptor = { | |
| 88 | + client: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG, | |
| 89 | + clientOwner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER, | |
| 90 | +}; | |
| 91 | + | |
| 92 | +/** Registering the provider needs the issuer to discover endpoints from and the | |
| 93 | + * client id the exchange authenticates as. The secret is optional: an identity | |
| 94 | + * provider may treat the registration as a public client. */ | |
| 81 | 95 | export const canSubmitEnterpriseIdentityProvider = (input: { |
| 82 | 96 | readonly submitting: boolean; |
| 83 | − readonly tokenUrl: string; | |
| 97 | + readonly issuerUrl: string; | |
| 84 | 98 | readonly clientId: string; |
| 85 | 99 | }): boolean => |
| 86 | − !input.submitting && input.tokenUrl.trim().length > 0 && input.clientId.trim().length > 0; | |
| 100 | + !input.submitting && input.issuerUrl.trim().length > 0 && input.clientId.trim().length > 0; | |
| 87 | 101 | |
| 88 | 102 | /** The `oauth.createClient` payload for the organization's identity provider. |
| 89 | 103 | * |
| 90 | − * `authorizationUrl` is empty by construction and that is not an omission: the | |
| 91 | − * enterprise-managed chain never runs a browser redirect through this | |
| 92 | − * registration. It exchanges an assertion the user already holds at the token | |
| 93 | − * endpoint (draft §4.3), so there is no authorization endpoint to record — and | |
| 94 | − * inventing one would claim a flow this app does not run. */ | |
| 104 | + * BOTH endpoints are recorded, and the authorization endpoint is the one worth | |
| 105 | + * explaining. The RFC 8693 exchange that mints an ID-JAG only ever touches the | |
| 106 | + * token endpoint — but the exchange needs a subject token ISSUED BY THIS | |
| 107 | + * PROVIDER and audienced to THIS client, and a WorkOS-brokered login does not | |
| 108 | + * produce one: there, WorkOS is the client at the customer's identity | |
| 109 | + * provider, and what executor holds is a WorkOS token. Closing that gap needs | |
| 110 | + * one authorization-code hop against this registration — the member's "link | |
| 111 | + * your work identity" step — and that hop needs an authorize endpoint. */ | |
| 95 | 112 | export const enterpriseIdentityProviderPayload = (input: { |
| 113 | + readonly authorizationUrl: string; | |
| 96 | 114 | readonly tokenUrl: string; |
| 97 | 115 | readonly clientId: string; |
| 98 | 116 | readonly clientSecret: string; |
| 99 | 117 | }) => ({ |
| 100 | 118 | owner: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_OWNER, |
| 101 | 119 | slug: ENTERPRISE_IDENTITY_PROVIDER_CLIENT_SLUG, |
| 102 | − authorizationUrl: "", | |
| 120 | + authorizationUrl: input.authorizationUrl.trim(), | |
| 103 | 121 | tokenUrl: input.tokenUrl.trim(), |
| 104 | 122 | grant: "authorization_code" as const, |
| 105 | 123 | clientId: input.clientId.trim(), |
| @@ -109,6 +127,22 @@ export const enterpriseIdentityProviderPayload = (input: { · 3 unmodified lines | ||
| 109 | 127 | originIntegration: null, |
| 110 | 128 | }); |
| 111 | 129 | |
| 130 | +/** | |
| 131 | + * The organization's identity-provider pointer, or null when none is | |
| 132 | + * registered. | |
| 133 | + * | |
| 134 | + * The pointer, deliberately, and not the registration: a server declaration and | |
| 135 | + * a connect request both need to NAME the provider, and neither has any | |
| 136 | + * business holding its endpoints or its client id. | |
| 137 | + */ | |
| 138 | +export function useEnterpriseIdentityProviderDescriptor(): EnterpriseIdentityProviderDescriptor | null { | |
| 139 | + const clientsResult = useAtomValue(oauthClientsOptimisticAtom); | |
| 140 | + return AsyncResult.isSuccess(clientsResult) && | |
| 141 | + findEnterpriseIdentityProvider(clientsResult.value) !== null | |
| 142 | + ? ENTERPRISE_IDENTITY_PROVIDER_DESCRIPTOR | |
| 143 | + : null; | |
| 144 | +} | |
| 145 | + | |
| 112 | 146 | // --------------------------------------------------------------------------- |
| 113 | 147 | // The registration dialog. Self-contained: its form state lives here and dies |
| 114 | 148 | // with it, so re-opening after a cancel never resurfaces a half-typed secret. |
| @@ -122,19 +156,36 @@ function EnterpriseIdentityProviderDialog(props: { · 7 unmodified lines | ||
| 122 | 156 | readonly current: OAuthClientSummary | null; |
| 123 | 157 | }) { |
| 124 | 158 | const { current } = props; |
| 125 | − const [tokenUrl, setTokenUrl] = useState(current === null ? "" : current.tokenUrl); | |
| 159 | + const [issuerUrl, setIssuerUrl] = useState(current === null ? "" : current.tokenUrl); | |
| 126 | 160 | const [clientId, setClientId] = useState(current === null ? "" : current.clientId); |
| 127 | 161 | const [clientSecret, setClientSecret] = useState(""); |
| 128 | 162 | const [submitting, setSubmitting] = useState(false); |
| 129 | 163 | const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" }); |
| 164 | + const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" }); | |
| 130 | 165 | |
| 131 | − const canSubmit = canSubmitEnterpriseIdentityProvider({ submitting, tokenUrl, clientId }); | |
| 166 | + const canSubmit = canSubmitEnterpriseIdentityProvider({ submitting, issuerUrl, clientId }); | |
| 132 | 167 | |
| 133 | 168 | const handleSubmit = async () => { |
| 134 | 169 | if (!canSubmit) return; |
| 135 | 170 | setSubmitting(true); |
| 171 | + // Endpoints are DISCOVERED from the issuer, never typed. An administrator | |
| 172 | + // knows their tenant's issuer; asking them to also transcribe an authorize | |
| 173 | + // and a token path invites the one typo whose only symptom is every | |
| 174 | + // member's connect failing later, at the identity provider, with a message | |
| 175 | + // about the wrong host. | |
| 176 | + const probed = await doProbe({ payload: { url: issuerUrl.trim() }, reactivityKeys: [] }); | |
| 177 | + if (Exit.isFailure(probed)) { | |
| 178 | + setSubmitting(false); | |
| 179 | + toast.error("Couldn't read that issuer's OpenID configuration. Check the URL, then retry."); | |
| 180 | + return; | |
| 181 | + } | |
| 136 | 182 | const exit = await doCreate({ |
| 137 | − payload: enterpriseIdentityProviderPayload({ tokenUrl, clientId, clientSecret }), | |
| 183 | + payload: enterpriseIdentityProviderPayload({ | |
| 184 | + authorizationUrl: probed.value.authorizationUrl, | |
| 185 | + tokenUrl: probed.value.tokenUrl, | |
| 186 | + clientId, | |
| 187 | + clientSecret, | |
| 188 | + }), | |
| 138 | 189 | reactivityKeys: oauthClientWriteKeys, |
| 139 | 190 | }); |
| 140 | 191 | trackEvent("oauth_client_registered", { |
| @@ -145,7 +196,7 @@ function EnterpriseIdentityProviderDialog(props: { · 4 unmodified lines | ||
| 145 | 196 | }); |
| 146 | 197 | if (Exit.isFailure(exit)) { |
| 147 | 198 | setSubmitting(false); |
| 148 | − toast.error("Couldn't save the identity provider. Check the token URL, then retry."); | |
| 199 | + toast.error("Couldn't save the identity provider. Check the issuer URL, then retry."); | |
| 149 | 200 | return; |
| 150 | 201 | } |
| 151 | 202 | toast.success(current === null ? "Identity provider registered" : "Identity provider updated"); |
| @@ -166,23 +217,27 @@ function EnterpriseIdentityProviderDialog(props: { · 14 unmodified lines | ||
| 166 | 217 | {current === null ? "Register Identity Provider" : "Edit Identity Provider"} |
| 167 | 218 | </DialogTitle> |
| 168 | 219 | <DialogDescription className="text-sm leading-relaxed"> |
| 169 | − Executor exchanges each member's single sign-on assertion at this endpoint for a | |
| 170 | − grant naming one MCP server. Your identity provider decides which members may reach | |
| 171 | − which servers. | |
| 220 | + Register Executor as an application in your own identity provider, then paste its | |
| 221 | + details here. Executor exchanges each member's work identity there for a grant | |
| 222 | + naming one MCP server, so your identity provider — not the member — decides who reaches | |
| 223 | + which server. | |
| 172 | 224 | </DialogDescription> |
| 173 | 225 | </DialogHeader> |
| 174 | 226 | |
| 175 | 227 | <div className="grid gap-4 py-1"> |
| 176 | 228 | <div className="grid gap-1.5"> |
| 177 | − <Label htmlFor="ema-idp-token-url" className="text-xs text-muted-foreground"> | |
| 178 | − Token URL | |
| 229 | + <Label htmlFor="ema-idp-issuer-url" className="text-xs text-muted-foreground"> | |
| 230 | + Issuer URL | |
| 231 | + <span className="font-normal text-muted-foreground/70"> | |
| 232 | + endpoints are read from it | |
| 233 | + </span> | |
| 179 | 234 | </Label> |
| 180 | 235 | <Input |
| 181 | − id="ema-idp-token-url" | |
| 236 | + id="ema-idp-issuer-url" | |
| 182 | 237 | autoComplete="off" |
| 183 | − placeholder="https://example.okta.com/oauth2/default/v1/token" | |
| 184 | − value={tokenUrl} | |
| 185 | − onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)} | |
| 238 | + placeholder="https://example.okta.com/oauth2/default" | |
| 239 | + value={issuerUrl} | |
| 240 | + onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIssuerUrl(e.target.value)} | |
| 186 | 241 | className="font-mono text-sm" |
| 187 | 242 | /> |
| 188 | 243 | </div> |
| @@ -226,7 +281,11 @@ function EnterpriseIdentityProviderDialog(props: { · 37 unmodified lines | ||
| 226 | 281 | </Button> |
| 227 | 282 | </DialogClose> |
| 228 | 283 | <Button size="sm" onClick={() => void handleSubmit()} disabled={!canSubmit}> |
| 229 | − {submitting ? "Saving…" : current === null ? "Register Provider" : "Save Provider"} | |
| 284 | + {submitting | |
| 285 | + ? "Reading issuer…" | |
| 286 | + : current === null | |
| 287 | + ? "Register Provider" | |
| 288 | + : "Save Provider"} | |
| 230 | 289 | </Button> |
| 231 | 290 | </DialogFooter> |
| 232 | 291 | </DialogContent> |
| @@ -261,8 +320,9 @@ export function EnterpriseIdentityProviderSection() { · 28 unmodified lines | ||
| 261 | 320 | <div> |
| 262 | 321 | <h2 className="text-sm font-medium text-foreground">Enterprise Identity Provider</h2> |
| 263 | 322 | <p className="mt-0.5 text-sm text-muted-foreground"> |
| 264 | − Register the app Executor uses at your identity provider. MCP servers pointed at it | |
| 265 | − connect through your organization instead of asking each member for consent. | |
| 323 | + Register Executor's application from your own identity provider. MCP servers marked | |
| 324 | + as managed then connect with each member's work identity, instead of asking them to | |
| 325 | + consent server by server. | |
| 266 | 326 | </p> |
| 267 | 327 | </div> |
| 268 | 328 | {provider === null ? ( |
| @@ -282,7 +342,7 @@ export function EnterpriseIdentityProviderSection() { · 13 unmodified lines | ||
| 282 | 342 | onSuccess: () => |
| 283 | 343 | provider === null ? ( |
| 284 | 344 | <p className="py-6 text-center text-sm text-muted-foreground"> |
| 285 | − No identity provider yet. Register one so MCP servers can authorize members through | |
| 345 | + No identity provider yet. Register one before marking any MCP server as managed by | |
| 286 | 346 | your organization. |
| 287 | 347 | </p> |
| 288 | 348 | ) : ( |
| @@ -336,8 +396,8 @@ export function EnterpriseIdentityProviderSection() { · 47 unmodified lines | ||
| 336 | 396 | </div> |
| 337 | 397 | <div className="border-t border-border px-4 py-3"> |
| 338 | 398 | <p className="text-xs text-muted-foreground"> |
| 339 | − Members never see a consent screen for servers pointed at this provider, and | |
| 340 | − access is revoked at the provider, not here. | |
| 399 | + On a managed server, members connect with their work identity instead of a consent | |
| 400 | + screen — and access is revoked at your identity provider, not here. | |
| 341 | 401 | </p> |
| 342 | 402 | </div> |
| 343 | 403 | </div> |
| @@ -16,7 +16,10 @@ · 15 unmodified lines | ||
| 16 | 16 | // --------------------------------------------------------------------------- |
| 17 | 17 | |
| 18 | 18 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; |
| 19 | −import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; | |
| 19 | +import type { | |
| 20 | + AuthMethodDescriptor, | |
| 21 | + EnterpriseIdentityProviderDescriptor, | |
| 22 | +} from "@executor-js/sdk/shared"; | |
| 20 | 23 | |
| 21 | 24 | export type Carrier = "header" | "query" | "env"; |
| 22 | 25 | |
| @@ -74,6 +77,17 @@ export interface AuthMethodOAuth { · 51 unmodified lines | ||
| 74 | 77 | * `client_id` is this host's metadata document URL, with no provider-side app |
| 75 | 78 | * registration. */ |
| 76 | 79 | readonly supportsClientIdMetadataDocument?: boolean; |
| 80 | + /** MCP Enterprise-Managed Authorization: which registered OAuth app stands for | |
| 81 | + * the organization's identity provider for this server. Present only when the | |
| 82 | + * server declares one. | |
| 83 | + * | |
| 84 | + * A POINTER, and only a pointer — no assertion, no secret. Its presence means | |
| 85 | + * "this server is enterprise-managed": the connect path may name the | |
| 86 | + * organization's identity provider on `oauth.start` instead of walking the | |
| 87 | + * member through per-server consent. It never removes the interactive route, | |
| 88 | + * because the server still has to advertise the grant profile for the | |
| 89 | + * enterprise branch to be taken at all. */ | |
| 90 | + readonly enterpriseIdentityProvider?: EnterpriseIdentityProviderDescriptor; | |
| 77 | 91 | } |
| 78 | 92 | |
| 79 | 93 | export interface AuthMethod { |
| @@ -177,6 +191,12 @@ function authMethodFromDescriptor(descriptor: AuthMethodDescriptor): AuthMethod · 97 unmodified lines | ||
| 177 | 191 | discoveryUrl: oauth?.discoveryUrl, |
| 178 | 192 | supportsDynamicRegistration: oauth?.supportsDynamicRegistration, |
| 179 | 193 | supportsClientIdMetadataDocument: oauth?.supportsClientIdMetadataDocument, |
| 194 | + // Carried, not re-derived: the deployment declared WHICH app stands for | |
| 195 | + // the organization's identity provider, and the connect path has to | |
| 196 | + // name that exact registration on `oauth.start`. Dropping it here would | |
| 197 | + // silently turn every enterprise-managed server back into an ordinary | |
| 198 | + // one in the console while the backend still expected the pointer. | |
| 199 | + enterpriseIdentityProvider: oauth?.enterpriseIdentityProvider, | |
| 180 | 200 | }, |
| 181 | 201 | }; |
| 182 | 202 | } |