History

Cover the work identity path end to end against the Okta emulator

A selfhost scenario runs the whole thing through the product: link via a real OIDC sign-in, connect with no subjectToken in the payload, call a tool. Both emulator ledgers carry the proof -- the sign-in, executor's own code redemption, the exchange and the redemption -- and the MCP server sees no authorize request. Renewal past ID-token expiry is not asserted here: the Okta emulator's token lifetimes are compiled-in constants with no seed knob to compress them. That claim is proven hermetically in the SDK suite instead.

Rhys Sullivan committed 2 months ago

Commit 2b6b7ed1 parent c4be99b1 file · +442 −0Browse files

Unified · Split

e2e/selfhost/mcp-ema-work-identity.test.ts+442−0
@@ -0,0 +1,442 @@
1+// Selfhost-only: the WORK IDENTITY half of MCP Enterprise-Managed Authorization,
2+// driven end to end against the same two emulators as
3+// `mcp-enterprise-managed-auth.test.ts`.
4+//
5+// That scenario proves the ID-JAG chain works when someone hands executor an
6+// identity assertion. This one proves the product can GET one — which is the
7+// part a browser cannot do, because the identity provider's client secret is
8+// server-side. The claim under test, in order:
9+//
10+// 1. the user links their enterprise identity ONCE, through a real OIDC
11+// authorization-code flow that executor builds and redeems itself, and
12+// 2. what executor takes custody of is the IdP's REFRESH token
13+// (draft-ietf-oauth-identity-assertion-authz-grant §4.5) — the durable
14+// subject, not the ~1h ID token, and
15+// 3. the enterprise-managed connect then carries NO subjectToken at all, and
16+// still connects with no consent step, and a tool call rides the result.
17+//
18+// Both emulators' request ledgers are the proof: executor's own responses only
19+// show what executor believes, while the ledgers show the upstream calls it
20+// actually made — the sign-in, the exchange, the redemption, and the tool call.
21+//
22+// NOT covered here, deliberately: renewal after the ID token would have expired.
23+// The Okta emulator's token lifetimes are compiled-in constants (access/ID token
24+// 3600s, ID-JAG 300s) with no seed knob to compress them, so a real
25+// past-expiry renewal is not expressible against it inside a test's budget. That
26+// claim is proven hermetically instead, in the SDK's `oauth-work-identity.test.ts`
27+// ("renews after the ID token that started the link would have died"), by
28+// revoking everything the sign-in issued that expires and showing renewal
29+// continues — which is precisely the state the hour brings.
30+import { randomBytes } from "node:crypto";
31+import { createServer } from "node:net";
32+
33+import { assert, expect } from "@effect/vitest";
34+import { Effect } from "effect";
35+import { composePluginApi } from "@executor-js/api/server";
36+import { createEmulator, type Emulator, type LedgerEntry } from "@executor-js/emulate";
37+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
38+import {
39+ AuthTemplateSlug,
40+ ConnectionName,
41+ IntegrationSlug,
42+ OAuthClientSlug,
43+} from "@executor-js/sdk/shared";
44+
45+import { scenario } from "../src/scenario";
46+import { Api, Target } from "../src/services";
47+
48+const api = composePluginApi([mcpHttpPlugin()] as const);
49+
50+const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
51+const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
52+const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token";
53+
54+// The Okta emulator's default seed: one user on the `default` authorization
55+// server. The OAuth client is minted per run so nothing depends on the sample
56+// client's id.
57+const OKTA_USER = "testuser@okta.local";
58+
59+// What the MCP emulator advertises in both its RFC 9728 and RFC 8414 metadata.
60+const SERVER_SCOPES = ["repo", "read:user"] as const;
61+
62+const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
63+
64+const availablePort = Effect.callback<number>((resume) => {
65+ const probe = createServer();
66+ probe.listen(0, "127.0.0.1", () => {
67+ const address = probe.address();
68+ const port = typeof address === "object" && address ? address.port : 0;
69+ probe.close(() => {
70+ resume(Effect.succeed(port));
71+ });
72+ });
73+});
74+
75+/** A locally spawned emulator on an OS-assigned port, closed with the scope —
76+ * local rather than hosted so the behavior asserted is the version this
77+ * checkout pins, not whatever was last deployed. */
78+const emulator = (service: "okta" | "mcp") =>
79+ Effect.acquireRelease(
80+ Effect.gen(function* () {
81+ const port = yield* availablePort;
82+ return yield* Effect.promise(() => createEmulator({ service, port }));
83+ }),
84+ (instance: Emulator) => Effect.promise(() => instance.close()).pipe(Effect.ignore),
85+ );
86+
87+const requireString = (value: string | undefined | null, what: string): string => {
88+ if (!value) throw new Error(`emulator returned no ${what}`);
89+ return value;
90+};
91+
92+/** Sign in at the identity provider against the authorization URL EXECUTOR
93+ * built, and hand back the code its callback would have received.
94+ *
95+ * This is the one step a headless scenario has to stand in for, and it stands
96+ * in for the browser only: every parameter posted below is read straight off
97+ * executor's own authorize URL, so the client id, redirect URI, scopes and PKCE
98+ * challenge under test are the ones the product chose, not ones invented here. */
99+const signInAt = (authorizationUrl: string) =>
100+ Effect.promise(async (): Promise<string> => {
101+ const authorize = new URL(authorizationUrl);
102+ const callbackUrl = new URL(authorize);
103+ callbackUrl.pathname = `${authorize.pathname}/callback`;
104+ callbackUrl.search = "";
105+
106+ const body = new URLSearchParams({ user_ref: OKTA_USER });
107+ for (const key of [
108+ "redirect_uri",
109+ "scope",
110+ "state",
111+ "nonce",
112+ "client_id",
113+ "code_challenge",
114+ "code_challenge_method",
115+ ]) {
116+ const value = authorize.searchParams.get(key);
117+ if (value !== null) body.set(key, value);
118+ }
119+ body.set("response_mode", "query");
120+
121+ const response = await fetch(callbackUrl.toString(), {
122+ method: "POST",
123+ headers: { "content-type": "application/x-www-form-urlencoded" },
124+ redirect: "manual",
125+ body,
126+ });
127+ if (response.status !== 302) {
128+ throw new Error(
129+ `IdP authorize answered ${response.status}, expected a 302: ${await response.text()}`,
130+ );
131+ }
132+ const location = requireString(response.headers.get("location"), "authorize redirect");
133+ return requireString(new URL(location).searchParams.get("code"), "authorization code");
134+ });
135+
136+const ledger = (instance: Emulator) => Effect.promise(() => instance.ledger.list());
137+
138+const entryFor = (entries: readonly LedgerEntry[], operationId: string): LedgerEntry | undefined =>
139+ entries.find((entry) => entry.operationId === operationId);
140+
141+const callGetMeCode = (slug: string, connection: string) => `
142+const result = await tools.${slug}.org.${connection}.get_me({});
143+return { ok: result.ok, payload: result.ok ? result.data : result.error };
144+`;
145+
146+type SandboxToolOutcome = {
147+ readonly ok: boolean;
148+ readonly payload?: { readonly login?: string };
149+};
150+
151+scenario(
152+ "MCP enterprise-managed authorization · linking a work identity once lets a connect carry no assertion at all",
153+ { timeout: 180_000 },
154+ Effect.scoped(
155+ Effect.gen(function* () {
156+ const target = yield* Target;
157+ const { client: makeApiClient } = yield* Api;
158+ const identity = yield* target.newIdentity();
159+ const client = yield* makeApiClient(api, identity);
160+
161+ const okta = yield* emulator("okta");
162+ const mcp = yield* emulator("mcp");
163+ const mcpEndpoint = `${mcp.url}/mcp`;
164+
165+ // The link redirects the user's browser back to EXECUTOR's own OAuth
166+ // callback — the same one every interactive connect uses. Registering it
167+ // with the identity provider is what an enterprise administrator does
168+ // once; the emulator matches redirect URIs exactly, so this scenario fails
169+ // loudly if executor ever asks for a different one.
170+ const executorCallback = new URL("/api/oauth/callback", target.baseUrl).toString();
171+
172+ // One client identity across BOTH registrations — the same client the user
173+ // signs in to, presenting itself to the Resource Authorization Server
174+ // (draft §5 client continuity).
175+ const credential = yield* Effect.promise(() =>
176+ okta.credentials.mint({
177+ type: "oauth-authorization-code",
178+ name: "Executor E2E work identity client",
179+ redirect_uris: [executorCallback],
180+ }),
181+ );
182+ const clientId = requireString(credential.client_id, "IdP client_id");
183+ const clientSecret = requireString(credential.client_secret, "IdP client_secret");
184+ const idpTokenUrl = requireString(credential.token_url, "IdP token endpoint");
185+ const idpAuthorizationUrl = requireString(credential.authorization_url, "IdP authorize URL");
186+
187+ const integration = IntegrationSlug.make(freshSlug("mcp_wid"));
188+ const idpClient = OAuthClientSlug.make(freshSlug("wid_idp"));
189+ const serverClient = OAuthClientSlug.make(freshSlug("wid_server"));
190+ const template = AuthTemplateSlug.make("oauth2");
191+ const workIdentityRef = {
192+ owner: "org",
193+ idpClient,
194+ idpClientOwner: "org",
195+ } as const;
196+
197+ yield* client.mcp.addServer({
198+ payload: {
199+ transport: "remote",
200+ name: "Enterprise-managed MCP (emulate)",
201+ endpoint: mcpEndpoint,
202+ slug: String(integration),
203+ authenticationTemplate: [
204+ {
205+ kind: "oauth2",
206+ enterpriseIdentityProvider: { client: idpClient, clientOwner: "org" },
207+ },
208+ ],
209+ },
210+ });
211+
212+ yield* Effect.ensuring(
213+ Effect.gen(function* () {
214+ // The client's registration AT THE IdP. Unlike the assertion-carrying
215+ // scenario, this one is RUN as a flow: the link is its authorization-
216+ // code flow, redeemed server-side with this secret.
217+ yield* client.oauth.createClient({
218+ payload: {
219+ owner: "org",
220+ slug: idpClient,
221+ authorizationUrl: idpAuthorizationUrl,
222+ tokenUrl: idpTokenUrl,
223+ grant: "authorization_code",
224+ clientId,
225+ clientSecret,
226+ },
227+ });
228+ // The client's registration AT THE RESOURCE AUTHORIZATION SERVER.
229+ yield* client.oauth.createClient({
230+ payload: {
231+ owner: "org",
232+ slug: serverClient,
233+ authorizationUrl: `${mcp.url}/authorize`,
234+ tokenUrl: `${mcp.url}/token`,
235+ grant: "id_jag",
236+ clientId,
237+ clientSecret,
238+ resource: mcpEndpoint,
239+ },
240+ });
241+
242+ // ---------------------------------------------------------------
243+ // Phase 1 — nothing is linked, so an assertion-less connect asks for
244+ // a LINK. This is the state every user starts in, and the field below
245+ // is what a console branches on to know what to offer.
246+ // ---------------------------------------------------------------
247+ expect(
248+ (yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status,
249+ "no work identity is held before the user links one",
250+ ).toBe("unlinked");
251+
252+ const unlinked = yield* client.oauth
253+ .start({
254+ payload: {
255+ owner: "org",
256+ client: serverClient,
257+ clientOwner: "org",
258+ name: ConnectionName.make("premature"),
259+ integration,
260+ template,
261+ enterprise: { idpClient, idpClientOwner: "org" },
262+ },
263+ })
264+ .pipe(Effect.flip);
265+ assert(
266+ unlinked._tag === "OAuthStartError",
267+ "an unlinked user is a start failure, not a transport or decoding fault",
268+ );
269+ expect(
270+ unlinked.workIdentityLinkRequired,
271+ "the remedy travels as a FIELD — a console cannot decide from a sentence whether to open the link flow, retry, or offer per-server consent",
272+ ).toBe(true);
273+
274+ // ---------------------------------------------------------------
275+ // Phase 2 — the link. A real OIDC authorization-code flow, built by
276+ // executor and redeemed by executor with the IdP app's secret.
277+ // ---------------------------------------------------------------
278+ const startedLink = yield* client.oauth.startWorkIdentityLink({
279+ payload: workIdentityRef,
280+ });
281+ const authorize = new URL(startedLink.authorizationUrl);
282+ expect(
283+ authorize.searchParams.get("redirect_uri"),
284+ "the link comes back to executor's own OAuth callback, so an enterprise registers one redirect URI and not two",
285+ ).toBe(executorCallback);
286+ expect(
287+ authorize.searchParams.get("scope")?.split(" ").sort(),
288+ "`openid` is where the account claims come from and `offline_access` is where the durable refresh token comes from",
289+ ).toEqual(["offline_access", "openid"]);
290+ expect(authorize.searchParams.get("client_id")).toBe(clientId);
291+
292+ const code = yield* signInAt(startedLink.authorizationUrl);
293+ const linked = yield* client.oauth.completeWorkIdentityLink({
294+ payload: { state: startedLink.state, code },
295+ });
296+
297+ assert(linked.status === "linked", "the completion reports the account that was linked");
298+ expect(
299+ linked.subjectTokenType,
300+ "§4.5: custody is the IdP's REFRESH token. An ID token here would strand every managed connection about an hour after it was made",
301+ ).toBe(REFRESH_TOKEN_TYPE);
302+ expect(
303+ linked.expiresAt,
304+ "and a refresh token carries no client-visible deadline, which is the property being bought",
305+ ).toBeNull();
306+ expect(linked.label, "the console can say WHO is linked").toContain("testuser");
307+
308+ expect(
309+ (yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status,
310+ "and the status a console polls agrees with the completion",
311+ ).toBe("linked");
312+
313+ // ---------------------------------------------------------------
314+ // Phase 3 — the headline: connect with NO assertion on the request.
315+ // ---------------------------------------------------------------
316+ const connected = yield* client.oauth.start({
317+ payload: {
318+ owner: "org",
319+ client: serverClient,
320+ clientOwner: "org",
321+ name: ConnectionName.make("main"),
322+ integration,
323+ template,
324+ // No `subjectToken`. Nothing in this payload could authorize
325+ // anything; the held identity is resolved server-side.
326+ enterprise: { idpClient, idpClientOwner: "org" },
327+ },
328+ });
329+
330+ assert(
331+ connected.status === "connected",
332+ "a linked user connects an enterprise-managed server with neither an assertion in hand nor a consent screen",
333+ );
334+ expect(
335+ connected.connection.oauthScope?.split(" ").sort(),
336+ "the connection carries the scopes the IdP granted",
337+ ).toEqual([...SERVER_SCOPES].sort());
338+
339+ const executed = yield* client.executions.execute({
340+ payload: { code: callGetMeCode(String(integration), "main"), autoApprove: true },
341+ });
342+ expect(executed.status, "the tool call completed").toBe("completed");
343+ const outcome = JSON.parse(executed.text) as SandboxToolOutcome;
344+ expect(outcome.ok, executed.text).toBe(true);
345+
346+ // --- Ledger: the identity provider saw a real sign-in. -----------
347+ const oktaEntries = yield* ledger(okta);
348+ expect(
349+ oktaEntries.some((entry) => entry.path.endsWith("/v1/authorize/callback")),
350+ "the link ran an actual OIDC sign-in at the identity provider",
351+ ).toBe(true);
352+ const codeExchange = oktaEntries.find(
353+ (entry) =>
354+ entry.path.endsWith("/v1/token") &&
355+ (entry.request.body as { readonly grant_type?: string } | undefined)?.grant_type ===
356+ "authorization_code",
357+ );
358+ expect(
359+ codeExchange?.response.status,
360+ "and executor — not the browser — redeemed the code, which is the only place the IdP app's secret may be used",
361+ ).toBe(200);
362+ expect(
363+ (codeExchange?.request.body as { readonly client_id?: string } | undefined)?.client_id,
364+ "as the registered enterprise client",
365+ ).toBe(clientId);
366+
367+ // --- Ledger: the connect exchanged that custody for an ID-JAG. ----
368+ const exchange = entryFor(oktaEntries, "okta.oauth.tokenExchange");
369+ expect(exchange, "executor ran an RFC 8693 exchange at the IdP").toBeTruthy();
370+ expect(exchange?.response.status).toBe(200);
371+ // `subject_token_type` is absent from the ledger: it redacts every
372+ // `*token*` field before recording. That the exchange presented the
373+ // REFRESH-token subject is asserted on the wire in the hermetic
374+ // protocol tests, and on the product surface by `subjectTokenType`
375+ // above — the ledger's job here is the routing fields.
376+ expect(
377+ exchange?.request.body,
378+ "the exchange names the MCP server's authorization server and resource",
379+ ).toMatchObject({
380+ grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
381+ audience: mcp.url,
382+ resource: mcpEndpoint,
383+ client_id: clientId,
384+ scope: SERVER_SCOPES.join(" "),
385+ });
386+
387+ // --- Ledger: what executor did with the ID-JAG it got back. ------
388+ const mcpEntries = yield* ledger(mcp);
389+ const redemption = entryFor(mcpEntries, "mcp.oauth.jwtBearer");
390+ expect(
391+ redemption,
392+ "executor redeemed the ID-JAG at the resource authorization server",
393+ ).toBeTruthy();
394+ expect(redemption?.response.status).toBe(200);
395+ expect(redemption?.request.body).toMatchObject({
396+ grant_type: JWT_BEARER_GRANT_TYPE,
397+ client_id: clientId,
398+ });
399+ expect(
400+ mcpEntries.filter((entry) => entry.path === "/authorize"),
401+ "no interactive authorization request was ever made at the MCP server",
402+ ).toEqual([]);
403+
404+ const toolCall = mcpEntries.find(
405+ (entry) => entry.path === "/mcp" && entry.method === "POST",
406+ );
407+ expect(
408+ toolCall?.identity.user,
409+ "the MCP server saw the enterprise user with the granted scopes",
410+ ).toMatchObject({ scopes: [...SERVER_SCOPES] });
411+
412+ // ---------------------------------------------------------------
413+ // Phase 4 — unlinking is a fact about the IDENTITY. The connection is
414+ // untouched; a re-link is what revives it.
415+ // ---------------------------------------------------------------
416+ yield* client.oauth.unlinkWorkIdentity({ payload: workIdentityRef });
417+ expect((yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status).toBe(
418+ "unlinked",
419+ );
420+ const connections = yield* client.connections.list({ query: { integration } });
421+ expect(
422+ connections.map((connection) => String(connection.name)).sort(),
423+ "forgetting the identity removes no connection — the premature attempt minted none, and the real one survives",
424+ ).toEqual(["main"]);
425+ }),
426+ Effect.gen(function* () {
427+ yield* client.oauth.unlinkWorkIdentity({ payload: workIdentityRef }).pipe(Effect.ignore);
428+ yield* client.connections
429+ .remove({ params: { owner: "org", integration, name: ConnectionName.make("main") } })
430+ .pipe(Effect.ignore);
431+ yield* client.oauth
432+ .removeClient({ params: { slug: serverClient }, payload: { owner: "org" } })
433+ .pipe(Effect.ignore);
434+ yield* client.oauth
435+ .removeClient({ params: { slug: idpClient }, payload: { owner: "org" } })
436+ .pipe(Effect.ignore);
437+ yield* client.mcp.removeServer({ params: { slug: integration } }).pipe(Effect.ignore);
438+ }),
439+ );
440+ }),
441+ ),
442+);