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.
30import { randomBytes } from "node:crypto";
31import { createServer } from "node:net";
32
33import { assert, expect } from "@effect/vitest";
34import { Effect } from "effect";
35import { composePluginApi } from "@executor-js/api/server";
36import { createEmulator, type Emulator, type LedgerEntry } from "@executor-js/emulate";
37import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
38import {
39 AuthTemplateSlug,
40 ConnectionName,
41 IntegrationSlug,
42 OAuthClientSlug,
43} from "@executor-js/sdk/shared";
44
45import { scenario } from "../src/scenario";
46import { Api, Target } from "../src/services";
47
48const api = composePluginApi([mcpHttpPlugin()] as const);
49
50const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
51const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
52const 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.
57const OKTA_USER = "testuser@okta.local";
58
59// What the MCP emulator advertises in both its RFC 9728 and RFC 8414 metadata.
60const SERVER_SCOPES = ["repo", "read:user"] as const;
61
62const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;
63
64const 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. */
78const 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
87const 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. */
99const 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
136const ledger = (instance: Emulator) => Effect.promise(() => instance.ledger.list());
137
138const entryFor = (entries: readonly LedgerEntry[], operationId: string): LedgerEntry | undefined =>
139 entries.find((entry) => entry.operationId === operationId);
140
141const callGetMeCode = (slug: string, connection: string) => `
142const result = await tools.${slug}.org.${connection}.get_me({});
143return { ok: result.ok, payload: result.ok ? result.data : result.error };
144`;
145
146type SandboxToolOutcome = {
147 readonly ok: boolean;
148 readonly payload?: { readonly login?: string };
149};
150
151scenario(
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);