diff --git a/e2e/selfhost/mcp-ema-work-identity.test.ts b/e2e/selfhost/mcp-ema-work-identity.test.ts new file mode 100644 index 0000000000..5e0298a44b --- /dev/null +++ b/e2e/selfhost/mcp-ema-work-identity.test.ts @@ -0,0 +1,442 @@ +// Selfhost-only: the WORK IDENTITY half of MCP Enterprise-Managed Authorization, +// driven end to end against the same two emulators as +// `mcp-enterprise-managed-auth.test.ts`. +// +// That scenario proves the ID-JAG chain works when someone hands executor an +// identity assertion. This one proves the product can GET one — which is the +// part a browser cannot do, because the identity provider's client secret is +// server-side. The claim under test, in order: +// +// 1. the user links their enterprise identity ONCE, through a real OIDC +// authorization-code flow that executor builds and redeems itself, and +// 2. what executor takes custody of is the IdP's REFRESH token +// (draft-ietf-oauth-identity-assertion-authz-grant §4.5) — the durable +// subject, not the ~1h ID token, and +// 3. the enterprise-managed connect then carries NO subjectToken at all, and +// still connects with no consent step, and a tool call rides the result. +// +// Both emulators' request ledgers are the proof: executor's own responses only +// show what executor believes, while the ledgers show the upstream calls it +// actually made — the sign-in, the exchange, the redemption, and the tool call. +// +// NOT covered here, deliberately: renewal after the ID token would have expired. +// The Okta emulator's token lifetimes are compiled-in constants (access/ID token +// 3600s, ID-JAG 300s) with no seed knob to compress them, so a real +// past-expiry renewal is not expressible against it inside a test's budget. That +// claim is proven hermetically instead, in the SDK's `oauth-work-identity.test.ts` +// ("renews after the ID token that started the link would have died"), by +// revoking everything the sign-in issued that expires and showing renewal +// continues — which is precisely the state the hour brings. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; + +import { assert, expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { createEmulator, type Emulator, type LedgerEntry } from "@executor-js/emulate"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; +const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token"; + +// The Okta emulator's default seed: one user on the `default` authorization +// server. The OAuth client is minted per run so nothing depends on the sample +// client's id. +const OKTA_USER = "testuser@okta.local"; + +// What the MCP emulator advertises in both its RFC 9728 and RFC 8414 metadata. +const SERVER_SCOPES = ["repo", "read:user"] as const; + +const freshSlug = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`; + +const availablePort = Effect.callback((resume) => { + const probe = createServer(); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + const port = typeof address === "object" && address ? address.port : 0; + probe.close(() => { + resume(Effect.succeed(port)); + }); + }); +}); + +/** A locally spawned emulator on an OS-assigned port, closed with the scope — + * local rather than hosted so the behavior asserted is the version this + * checkout pins, not whatever was last deployed. */ +const emulator = (service: "okta" | "mcp") => + Effect.acquireRelease( + Effect.gen(function* () { + const port = yield* availablePort; + return yield* Effect.promise(() => createEmulator({ service, port })); + }), + (instance: Emulator) => Effect.promise(() => instance.close()).pipe(Effect.ignore), + ); + +const requireString = (value: string | undefined | null, what: string): string => { + if (!value) throw new Error(`emulator returned no ${what}`); + return value; +}; + +/** Sign in at the identity provider against the authorization URL EXECUTOR + * built, and hand back the code its callback would have received. + * + * This is the one step a headless scenario has to stand in for, and it stands + * in for the browser only: every parameter posted below is read straight off + * executor's own authorize URL, so the client id, redirect URI, scopes and PKCE + * challenge under test are the ones the product chose, not ones invented here. */ +const signInAt = (authorizationUrl: string) => + Effect.promise(async (): Promise => { + const authorize = new URL(authorizationUrl); + const callbackUrl = new URL(authorize); + callbackUrl.pathname = `${authorize.pathname}/callback`; + callbackUrl.search = ""; + + const body = new URLSearchParams({ user_ref: OKTA_USER }); + for (const key of [ + "redirect_uri", + "scope", + "state", + "nonce", + "client_id", + "code_challenge", + "code_challenge_method", + ]) { + const value = authorize.searchParams.get(key); + if (value !== null) body.set(key, value); + } + body.set("response_mode", "query"); + + const response = await fetch(callbackUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + redirect: "manual", + body, + }); + if (response.status !== 302) { + throw new Error( + `IdP authorize answered ${response.status}, expected a 302: ${await response.text()}`, + ); + } + const location = requireString(response.headers.get("location"), "authorize redirect"); + return requireString(new URL(location).searchParams.get("code"), "authorization code"); + }); + +const ledger = (instance: Emulator) => Effect.promise(() => instance.ledger.list()); + +const entryFor = (entries: readonly LedgerEntry[], operationId: string): LedgerEntry | undefined => + entries.find((entry) => entry.operationId === operationId); + +const callGetMeCode = (slug: string, connection: string) => ` +const result = await tools.${slug}.org.${connection}.get_me({}); +return { ok: result.ok, payload: result.ok ? result.data : result.error }; +`; + +type SandboxToolOutcome = { + readonly ok: boolean; + readonly payload?: { readonly login?: string }; +}; + +scenario( + "MCP enterprise-managed authorization · linking a work identity once lets a connect carry no assertion at all", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + + const okta = yield* emulator("okta"); + const mcp = yield* emulator("mcp"); + const mcpEndpoint = `${mcp.url}/mcp`; + + // The link redirects the user's browser back to EXECUTOR's own OAuth + // callback — the same one every interactive connect uses. Registering it + // with the identity provider is what an enterprise administrator does + // once; the emulator matches redirect URIs exactly, so this scenario fails + // loudly if executor ever asks for a different one. + const executorCallback = new URL("/api/oauth/callback", target.baseUrl).toString(); + + // One client identity across BOTH registrations — the same client the user + // signs in to, presenting itself to the Resource Authorization Server + // (draft §5 client continuity). + const credential = yield* Effect.promise(() => + okta.credentials.mint({ + type: "oauth-authorization-code", + name: "Executor E2E work identity client", + redirect_uris: [executorCallback], + }), + ); + const clientId = requireString(credential.client_id, "IdP client_id"); + const clientSecret = requireString(credential.client_secret, "IdP client_secret"); + const idpTokenUrl = requireString(credential.token_url, "IdP token endpoint"); + const idpAuthorizationUrl = requireString(credential.authorization_url, "IdP authorize URL"); + + const integration = IntegrationSlug.make(freshSlug("mcp_wid")); + const idpClient = OAuthClientSlug.make(freshSlug("wid_idp")); + const serverClient = OAuthClientSlug.make(freshSlug("wid_server")); + const template = AuthTemplateSlug.make("oauth2"); + const workIdentityRef = { + owner: "org", + idpClient, + idpClientOwner: "org", + } as const; + + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Enterprise-managed MCP (emulate)", + endpoint: mcpEndpoint, + slug: String(integration), + authenticationTemplate: [ + { + kind: "oauth2", + enterpriseIdentityProvider: { client: idpClient, clientOwner: "org" }, + }, + ], + }, + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + // The client's registration AT THE IdP. Unlike the assertion-carrying + // scenario, this one is RUN as a flow: the link is its authorization- + // code flow, redeemed server-side with this secret. + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: idpClient, + authorizationUrl: idpAuthorizationUrl, + tokenUrl: idpTokenUrl, + grant: "authorization_code", + clientId, + clientSecret, + }, + }); + // The client's registration AT THE RESOURCE AUTHORIZATION SERVER. + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: serverClient, + authorizationUrl: `${mcp.url}/authorize`, + tokenUrl: `${mcp.url}/token`, + grant: "id_jag", + clientId, + clientSecret, + resource: mcpEndpoint, + }, + }); + + // --------------------------------------------------------------- + // Phase 1 — nothing is linked, so an assertion-less connect asks for + // a LINK. This is the state every user starts in, and the field below + // is what a console branches on to know what to offer. + // --------------------------------------------------------------- + expect( + (yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status, + "no work identity is held before the user links one", + ).toBe("unlinked"); + + const unlinked = yield* client.oauth + .start({ + payload: { + owner: "org", + client: serverClient, + clientOwner: "org", + name: ConnectionName.make("premature"), + integration, + template, + enterprise: { idpClient, idpClientOwner: "org" }, + }, + }) + .pipe(Effect.flip); + assert( + unlinked._tag === "OAuthStartError", + "an unlinked user is a start failure, not a transport or decoding fault", + ); + expect( + unlinked.workIdentityLinkRequired, + "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", + ).toBe(true); + + // --------------------------------------------------------------- + // Phase 2 — the link. A real OIDC authorization-code flow, built by + // executor and redeemed by executor with the IdP app's secret. + // --------------------------------------------------------------- + const startedLink = yield* client.oauth.startWorkIdentityLink({ + payload: workIdentityRef, + }); + const authorize = new URL(startedLink.authorizationUrl); + expect( + authorize.searchParams.get("redirect_uri"), + "the link comes back to executor's own OAuth callback, so an enterprise registers one redirect URI and not two", + ).toBe(executorCallback); + expect( + authorize.searchParams.get("scope")?.split(" ").sort(), + "`openid` is where the account claims come from and `offline_access` is where the durable refresh token comes from", + ).toEqual(["offline_access", "openid"]); + expect(authorize.searchParams.get("client_id")).toBe(clientId); + + const code = yield* signInAt(startedLink.authorizationUrl); + const linked = yield* client.oauth.completeWorkIdentityLink({ + payload: { state: startedLink.state, code }, + }); + + assert(linked.status === "linked", "the completion reports the account that was linked"); + expect( + linked.subjectTokenType, + "§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", + ).toBe(REFRESH_TOKEN_TYPE); + expect( + linked.expiresAt, + "and a refresh token carries no client-visible deadline, which is the property being bought", + ).toBeNull(); + expect(linked.label, "the console can say WHO is linked").toContain("testuser"); + + expect( + (yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status, + "and the status a console polls agrees with the completion", + ).toBe("linked"); + + // --------------------------------------------------------------- + // Phase 3 — the headline: connect with NO assertion on the request. + // --------------------------------------------------------------- + const connected = yield* client.oauth.start({ + payload: { + owner: "org", + client: serverClient, + clientOwner: "org", + name: ConnectionName.make("main"), + integration, + template, + // No `subjectToken`. Nothing in this payload could authorize + // anything; the held identity is resolved server-side. + enterprise: { idpClient, idpClientOwner: "org" }, + }, + }); + + assert( + connected.status === "connected", + "a linked user connects an enterprise-managed server with neither an assertion in hand nor a consent screen", + ); + expect( + connected.connection.oauthScope?.split(" ").sort(), + "the connection carries the scopes the IdP granted", + ).toEqual([...SERVER_SCOPES].sort()); + + const executed = yield* client.executions.execute({ + payload: { code: callGetMeCode(String(integration), "main"), autoApprove: true }, + }); + expect(executed.status, "the tool call completed").toBe("completed"); + const outcome = JSON.parse(executed.text) as SandboxToolOutcome; + expect(outcome.ok, executed.text).toBe(true); + + // --- Ledger: the identity provider saw a real sign-in. ----------- + const oktaEntries = yield* ledger(okta); + expect( + oktaEntries.some((entry) => entry.path.endsWith("/v1/authorize/callback")), + "the link ran an actual OIDC sign-in at the identity provider", + ).toBe(true); + const codeExchange = oktaEntries.find( + (entry) => + entry.path.endsWith("/v1/token") && + (entry.request.body as { readonly grant_type?: string } | undefined)?.grant_type === + "authorization_code", + ); + expect( + codeExchange?.response.status, + "and executor — not the browser — redeemed the code, which is the only place the IdP app's secret may be used", + ).toBe(200); + expect( + (codeExchange?.request.body as { readonly client_id?: string } | undefined)?.client_id, + "as the registered enterprise client", + ).toBe(clientId); + + // --- Ledger: the connect exchanged that custody for an ID-JAG. ---- + const exchange = entryFor(oktaEntries, "okta.oauth.tokenExchange"); + expect(exchange, "executor ran an RFC 8693 exchange at the IdP").toBeTruthy(); + expect(exchange?.response.status).toBe(200); + // `subject_token_type` is absent from the ledger: it redacts every + // `*token*` field before recording. That the exchange presented the + // REFRESH-token subject is asserted on the wire in the hermetic + // protocol tests, and on the product surface by `subjectTokenType` + // above — the ledger's job here is the routing fields. + expect( + exchange?.request.body, + "the exchange names the MCP server's authorization server and resource", + ).toMatchObject({ + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + audience: mcp.url, + resource: mcpEndpoint, + client_id: clientId, + scope: SERVER_SCOPES.join(" "), + }); + + // --- Ledger: what executor did with the ID-JAG it got back. ------ + const mcpEntries = yield* ledger(mcp); + const redemption = entryFor(mcpEntries, "mcp.oauth.jwtBearer"); + expect( + redemption, + "executor redeemed the ID-JAG at the resource authorization server", + ).toBeTruthy(); + expect(redemption?.response.status).toBe(200); + expect(redemption?.request.body).toMatchObject({ + grant_type: JWT_BEARER_GRANT_TYPE, + client_id: clientId, + }); + expect( + mcpEntries.filter((entry) => entry.path === "/authorize"), + "no interactive authorization request was ever made at the MCP server", + ).toEqual([]); + + const toolCall = mcpEntries.find( + (entry) => entry.path === "/mcp" && entry.method === "POST", + ); + expect( + toolCall?.identity.user, + "the MCP server saw the enterprise user with the granted scopes", + ).toMatchObject({ scopes: [...SERVER_SCOPES] }); + + // --------------------------------------------------------------- + // Phase 4 — unlinking is a fact about the IDENTITY. The connection is + // untouched; a re-link is what revives it. + // --------------------------------------------------------------- + yield* client.oauth.unlinkWorkIdentity({ payload: workIdentityRef }); + expect((yield* client.oauth.workIdentityStatus({ query: workIdentityRef })).status).toBe( + "unlinked", + ); + const connections = yield* client.connections.list({ query: { integration } }); + expect( + connections.map((connection) => String(connection.name)).sort(), + "forgetting the identity removes no connection — the premature attempt minted none, and the real one survives", + ).toEqual(["main"]); + }), + Effect.gen(function* () { + yield* client.oauth.unlinkWorkIdentity({ payload: workIdentityRef }).pipe(Effect.ignore); + yield* client.connections + .remove({ params: { owner: "org", integration, name: ConnectionName.make("main") } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: serverClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: idpClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.mcp.removeServer({ params: { slug: integration } }).pipe(Effect.ignore); + }), + ); + }), + ), +); diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 92c3e5ed72..10f1381b03 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -18,8 +18,10 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + WorkIdentityLinkError, type Connection, type ConnectResult, + type OAuthCallbackCompletion, } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; @@ -32,6 +34,7 @@ const decodeOAuthStartError = Schema.decodeUnknownOption(OAuthStartError); const decodeOAuthCompleteError = Schema.decodeUnknownOption(OAuthCompleteError); const decodeOAuthProbeError = Schema.decodeUnknownOption(OAuthProbeError); const decodeOAuthSessionNotFoundError = Schema.decodeUnknownOption(OAuthSessionNotFoundError); +const decodeWorkIdentityLinkError = Schema.decodeUnknownOption(WorkIdentityLinkError); const connectionToResponse = (c: Connection) => ({ owner: c.owner, @@ -57,6 +60,19 @@ const startResultToResponse = (result: ConnectResult) => state: result.state, }; +/** What the popup posts back to the opener for each flow the shared callback can + * complete. + * + * A connection keeps the historical shape verbatim — spread flat into the + * message — because openers already read its fields. A work identity is spread + * under its own key instead of flat: the two objects share field names + * (`owner`, for one) and a console must be able to tell which arrived by + * looking, not by guessing from overlapping keys. */ +const callbackToPopupPayload = (completion: OAuthCallbackCompletion) => + completion.kind === "connection" + ? connectionToResponse(completion.connection) + : { workIdentity: completion.workIdentity }; + const toPopupErrorMessage = (error: unknown): PopupErrorMessage => { const completeError = decodeOAuthCompleteError(error); if (Option.isSome(completeError)) @@ -72,6 +88,13 @@ const toPopupErrorMessage = (error: unknown): PopupErrorMessage => { details: startError.value.message, }; + const linkError = decodeWorkIdentityLinkError(error); + if (Option.isSome(linkError)) + return { + short: "Could not link your work identity", + details: linkError.value.message, + }; + const probeError = decodeOAuthProbeError(error); if (Option.isSome(probeError)) return { @@ -202,16 +225,73 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler }), ), ) + .handle("startWorkIdentityLink", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.oauth.startWorkIdentityLink({ + owner: payload.owner, + idpClient: payload.idpClient, + idpClientOwner: payload.idpClientOwner, + scopes: payload.scopes, + redirectUri: payload.redirectUri, + }); + }), + ), + ) + .handle("completeWorkIdentityLink", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.oauth.completeWorkIdentityLink({ + state: payload.state, + code: payload.code, + }); + }), + ), + ) + .handle("workIdentityStatus", ({ query }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.oauth.workIdentityStatus({ + owner: query.owner, + idpClient: query.idpClient, + idpClientOwner: query.idpClientOwner, + }); + }), + ), + ) + .handle("unlinkWorkIdentity", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.oauth.unlinkWorkIdentity({ + owner: payload.owner, + idpClient: payload.idpClient, + idpClientOwner: payload.idpClientOwner, + }); + return { unlinked: true }; + }), + ), + ) .handle("callback", ({ query: urlParams }) => // The callback always renders HTML, even on failure — the popup shows the // error + messages it back to the opener. + // + // BOTH browser flows land here: connecting an integration, and linking a + // work identity. `completeCallback` reads the in-flight session to decide + // which, so the route infers nothing from the URL. The popup payload stays + // backward compatible — a connection is still spread flat, exactly as + // before — and a link is spread as `{ workIdentity }`, which is how an + // opener tells the two apart without the connection shape changing. capture( Effect.gen(function* () { const executor = yield* ExecutorService; const html = yield* runOAuthCallback({ complete: ({ state, code, callbackDomain }) => executor.oauth - .complete({ + .completeCallback({ // `runOAuthCallback`'s `state` is a raw string from the URL; // the SDK speaks the branded `OAuthState` (nominal brand). state: OAuthState.make(state), @@ -219,6 +299,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler callbackDomain, }) .pipe( + Effect.map(callbackToPopupPayload), Effect.tapError((cause: unknown) => Effect.logError("OAuth callback completion failed", cause), ), diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 96e76a26c1..2ef465b234 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -30,6 +30,8 @@ import { OAuthState, Owner, ProviderKey, + WorkIdentityLinkError, + WorkIdentityStatusSchema, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- @@ -256,6 +258,64 @@ const CallbackUrlParams = Schema.Struct({ const HtmlResponse = Schema.String.pipe(HttpApiSchema.asText()); +// --------------------------------------------------------------------------- +// Work identity — acquiring the enterprise assertion an EMA connect presents. +// +// A user links their enterprise identity ONCE per (owner, IdP app); every +// enterprise-managed connect afterwards omits `enterprise.subjectToken` and the +// server resolves the held identity. The console's loop is: +// +// GET /oauth/work-identity/status → `unlinked` / `linked` / `needs_relink` +// POST /oauth/work-identity/start → open `authorizationUrl` in the popup +// (the popup lands on the SHARED /oauth/callback and posts back a +// `{ workIdentity: }` payload; `complete` below is the direct entry +// point for callers that catch the code themselves) +// POST /oauth/work-identity/complete +// DELETE /oauth/work-identity → forget it +// +// The three routing fields (`owner`, `idpClient`, `idpClientOwner`) are the same +// on every one of them: the integration catalog projects `idpClient` / +// `idpClientOwner` on the server's oauth auth method, and `owner` is the owner +// the connection will be made under. +// --------------------------------------------------------------------------- + +const WorkIdentityRefFields = { + /** The owner the identity is held under — the same owner the + * enterprise-managed CONNECTIONS backed by it are made under. */ + owner: Owner, + /** The registered OAuth app standing for the enterprise identity provider, as + * the integration catalog's `enterpriseIdentityProvider` names it. */ + idpClient: OAuthClientSlug, + idpClientOwner: Owner, +} as const; + +const StartWorkIdentityLinkPayload = Schema.Struct({ + ...WorkIdentityRefFields, + /** Replace the default `openid offline_access` request outright. Send only for + * an identity provider that needs different scope names — the defaults are + * what make the link return account claims and a durable refresh token. */ + scopes: Schema.optional(Schema.Array(Schema.String)), + redirectUri: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const StartWorkIdentityLinkResponse = Schema.Struct({ + authorizationUrl: Schema.String, + state: OAuthState, +}); + +const CompleteWorkIdentityLinkPayload = Schema.Struct({ + state: OAuthState, + code: Schema.String, +}); + +const WorkIdentityStatusUrlParams = Schema.Struct(WorkIdentityRefFields); + +const UnlinkWorkIdentityPayload = Schema.Struct(WorkIdentityRefFields); + +const UnlinkWorkIdentityResponse = Schema.Struct({ + unlinked: Schema.Boolean, +}); + // --------------------------------------------------------------------------- // Error schemas with HTTP status annotations // --------------------------------------------------------------------------- @@ -265,6 +325,7 @@ const OAuthComplete = OAuthCompleteError.annotate({ httpApiStatus: 400 }); const OAuthProbe = OAuthProbeError.annotate({ httpApiStatus: 400 }); const OAuthRegisterDynamic = OAuthRegisterDynamicError.annotate({ httpApiStatus: 400 }); const OAuthSessionNotFound = OAuthSessionNotFoundError.annotate({ httpApiStatus: 404 }); +const WorkIdentityLink = WorkIdentityLinkError.annotate({ httpApiStatus: 400 }); // --------------------------------------------------------------------------- // Group @@ -327,6 +388,34 @@ export const OAuthApi = HttpApiGroup.make("oauth") error: [InternalError, OAuthProbe], }), ) + .add( + HttpApiEndpoint.post("startWorkIdentityLink", "/oauth/work-identity/start", { + payload: StartWorkIdentityLinkPayload, + success: StartWorkIdentityLinkResponse, + error: [InternalError, WorkIdentityLink], + }), + ) + .add( + HttpApiEndpoint.post("completeWorkIdentityLink", "/oauth/work-identity/complete", { + payload: CompleteWorkIdentityLinkPayload, + success: WorkIdentityStatusSchema, + error: [InternalError, WorkIdentityLink, OAuthSessionNotFound], + }), + ) + .add( + HttpApiEndpoint.get("workIdentityStatus", "/oauth/work-identity/status", { + query: WorkIdentityStatusUrlParams, + success: WorkIdentityStatusSchema, + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.delete("unlinkWorkIdentity", "/oauth/work-identity", { + payload: UnlinkWorkIdentityPayload, + success: UnlinkWorkIdentityResponse, + error: InternalError, + }), + ) .add( HttpApiEndpoint.get("callback", "/oauth/callback", { query: CallbackUrlParams, diff --git a/packages/core/api/src/oauth/work-identity-handlers.test.ts b/packages/core/api/src/oauth/work-identity-handlers.test.ts new file mode 100644 index 0000000000..f9c5a725a0 --- /dev/null +++ b/packages/core/api/src/oauth/work-identity-handlers.test.ts @@ -0,0 +1,256 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; + +import { OAuthClientSlug, createExecutor, type Executor } from "@executor-js/sdk"; +import { + makeTestConfig, + memoryCredentialsPlugin, + serveOAuthTestServer, + type OAuthTestServerShape, +} from "@executor-js/sdk/testing"; + +import { ExecutorApi } from "../api"; +import { observabilityMiddleware } from "../observability"; +import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; + +// --------------------------------------------------------------------------- +// The work-identity HTTP surface — the four routes a console drives to acquire +// the enterprise assertion an enterprise-managed connect presents. +// +// The link flow's own behavior (which token is taken into custody, what a +// rejection means, how a re-link revives connections) is proven in the SDK's +// `oauth-work-identity.test.ts` against the identity provider's ledger. What +// this file proves is the EDGE on top of it: +// +// - the wire shapes and status codes the contract promises, +// - that the status projection is safe to hand a browser: no field anywhere in +// it can carry, or help reconstruct, the held credential, +// - and that the routing fields round-trip through query params and payloads. +// --------------------------------------------------------------------------- + +const IDP_CLIENT = OAuthClientSlug.make("enterprise-idp"); +const IDP_CLIENT_ID = "client-at-idp"; + +/** Every shape a held subject token could travel in. NONE of these keys may + * appear in a work-identity HTTP response: the status is a browser-facing + * projection of a credential record, and the one thing it must never carry is + * the credential. Enumerated so a future field addition has to argue with it. */ +const FORBIDDEN_FIELDS = [ + "token", + "subjectToken", + "subject_token", + "refreshToken", + "refresh_token", + "idToken", + "id_token", + "accessToken", + "access_token", + "clientSecret", + "client_secret", + "secret", +] as const; + +const assertNoCredentialFields = (body: unknown, where: string): void => { + const serialized = JSON.stringify(body); + for (const field of FORBIDDEN_FIELDS) { + expect(serialized, `${where} must not carry ${field}`).not.toContain(`"${field}"`); + } +}; + +const webHandlerFor = (executor: Executor) => + Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(ExecutorApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(observabilityMiddleware(ExecutorApi)), + Layer.provide(Layer.succeed(ExecutorService)(executor)), + Layer.provide( + Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ), + { disableLogger: true }, + ), + ), + (web) => Effect.promise(() => web.dispose()), + ); + +const handlerContextFor = (executor: Executor) => + Context.make(ExecutorService, executor).pipe( + Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), + ); + +type Caller = { + readonly json: ( + method: "POST" | "DELETE", + path: string, + payload: unknown, + ) => Effect.Effect<{ readonly status: number; readonly body: unknown }>; + readonly get: ( + path: string, + ) => Effect.Effect<{ readonly status: number; readonly body: unknown }>; +}; + +const callerFor = (executor: Executor) => + Effect.gen(function* () { + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + const send = (request: Request) => + Effect.promise(async () => { + const response = await web.handler(request, context); + return { status: response.status, body: (await response.json()) as unknown }; + }); + return { + json: (method, path, payload) => + send( + new Request(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ), + get: (path) => send(new Request(`http://localhost${path}`)), + } satisfies Caller; + }); + +/** An identity provider plus a registered app pointing at it, which is the only + * setup a link needs — no integration, no connection, no MCP server. */ +const enterpriseIdp = () => + Effect.gen(function* () { + const idp = yield* serveOAuthTestServer({ + clients: { [IDP_CLIENT_ID]: null }, + idTokenClaims: { sub: "00u-enterprise-1", email: "alice@enterprise.test" }, + enterpriseIdp: {}, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [memoryCredentialsPlugin()] as const }), + ); + yield* executor.oauth.createClient({ + owner: "org", + slug: IDP_CLIENT, + authorizationUrl: idp.authorizationEndpoint, + tokenUrl: idp.tokenEndpoint, + grant: "authorization_code", + clientId: IDP_CLIENT_ID, + clientSecret: "", + }); + return { idp, executor, caller: yield* callerFor(executor) }; + }); + +const REF = { owner: "org", idpClient: String(IDP_CLIENT), idpClientOwner: "org" } as const; +const STATUS_QUERY = `/oauth/work-identity/status?owner=${REF.owner}&idpClient=${encodeURIComponent( + REF.idpClient, +)}&idpClientOwner=${REF.idpClientOwner}`; + +interface StartBody { + readonly authorizationUrl: string; + readonly state: string; +} +interface StatusBody { + readonly status: string; + readonly label?: string | null; + readonly subject?: string | null; + readonly subjectTokenType?: string; + readonly idpClient?: string; +} + +const linkThroughHttp = (setup: { readonly idp: OAuthTestServerShape; readonly caller: Caller }) => + Effect.gen(function* () { + const started = yield* setup.caller.json("POST", "/oauth/work-identity/start", REF); + expect(started.status, "starting a link is a plain 200 with somewhere to send the user").toBe( + 200, + ); + const start = started.body as StartBody; + const callback = yield* setup.idp.completeAuthorizationCodeFlow({ + authorizationUrl: start.authorizationUrl, + }); + return yield* setup.caller.json("POST", "/oauth/work-identity/complete", { + state: start.state, + code: callback.code, + }); + }); + +describe("work identity HTTP surface", () => { + it.effect("links, reports the account, and forgets it again", () => + Effect.scoped( + Effect.gen(function* () { + const setup = yield* enterpriseIdp(); + + const before = yield* setup.caller.get(STATUS_QUERY); + expect(before.status).toBe(200); + expect((before.body as StatusBody).status, "nothing is held before the link").toBe( + "unlinked", + ); + + const completed = yield* linkThroughHttp(setup); + expect(completed.status).toBe(200); + const linked = completed.body as StatusBody; + expect(linked.status).toBe("linked"); + expect( + linked.label, + "the console shows WHICH enterprise account is linked, so the completion already carries it", + ).toBe("alice@enterprise.test"); + expect(linked.subject).toBe("00u-enterprise-1"); + expect( + linked.subjectTokenType, + "and which custody was taken, so a product can warn about the expiring one", + ).toBe("urn:ietf:params:oauth:token-type:refresh_token"); + assertNoCredentialFields(completed.body, "the link completion response"); + + const after = yield* setup.caller.get(STATUS_QUERY); + expect( + (after.body as StatusBody).status, + "the poll a console runs sees the same thing the completion returned", + ).toBe("linked"); + expect((after.body as StatusBody).idpClient, "keyed by the app it was linked at").toBe( + REF.idpClient, + ); + assertNoCredentialFields(after.body, "the status response"); + + const unlinked = yield* setup.caller.json("DELETE", "/oauth/work-identity", REF); + expect(unlinked.status).toBe(200); + expect(unlinked.body).toEqual({ unlinked: true }); + expect((yield* setup.caller.get(STATUS_QUERY)).body).toMatchObject({ status: "unlinked" }); + }), + ), + ); + + it.effect("reports an unknown identity provider app as a link failure, not a server error", () => + Effect.scoped( + Effect.gen(function* () { + const setup = yield* enterpriseIdp(); + + const failed = yield* setup.caller.json("POST", "/oauth/work-identity/start", { + ...REF, + idpClient: "no-such-app", + }); + + expect( + failed.status, + "naming an app that does not exist is the caller's mistake and is answerable", + ).toBe(400); + expect(failed.body).toMatchObject({ _tag: "WorkIdentityLinkError" }); + }), + ), + ); + + it.effect("rejects a completion whose state was never issued", () => + Effect.scoped( + Effect.gen(function* () { + const setup = yield* enterpriseIdp(); + + const failed = yield* setup.caller.json("POST", "/oauth/work-identity/complete", { + state: "not-a-real-state", + code: "irrelevant", + }); + + expect(failed.status).toBe(404); + expect(failed.body).toMatchObject({ _tag: "OAuthSessionNotFoundError" }); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index faf5e44a21..e7a87d4f79 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -201,6 +201,13 @@ export class CredentialResolutionError extends Schema.TaggedErrorClass => { + const health: HealthCheckResult = { status: "expired", checkedAt: Date.now(), detail }; + return core + .updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(row.owner as Owner)(b), + b("integration", "=", String(row.integration)), + b("name", "=", String(row.name)), + ), + set: { last_health: health, updated_at: new Date() }, + }) + .pipe(Effect.ignore); + }; + + /** Record the IdP's rejection ON THE WORK IDENTITY, so every other + * enterprise-managed connection backed by it short-circuits instead of + * spending its own doomed exchange, and so the console can offer the one + * action that helps. Best-effort, exactly like `markRefreshGrantDead`: a + * bookkeeping write failure must not mask the failure being reported. */ + const markWorkIdentityRejected = ( + provider: CredentialProvider, + itemId: string, + ): Effect.Effect => + Effect.gen(function* () { + if (!provider.set) return; + const stored = yield* provider.get(ProviderItemId.make(itemId)); + if (stored === null) return; + const record = Option.getOrNull(decodeWorkIdentityRecord(stored)); + if (record === null || !isWorkIdentityUsable(record)) return; + yield* provider.set( + ProviderItemId.make(itemId), + encodeWorkIdentityRecord( + revokedWorkIdentity(record, { at: Date.now(), reason: "rejected" }), + ), + ); + }).pipe(Effect.ignore); + /** The rendered message of a typed enterprise-managed failure. */ const enterpriseManagedMessage = (cause: EnterpriseManagedMintError): string => // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: every EMA error declares `message` as a getter over its own typed fields, so this is a projection of a typed failure, not a read off an unknown throwable @@ -1943,12 +2001,53 @@ export const createExecutor = + new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message, + reauthRequired: true, + workIdentityRelinkRequired: true, + }); + /** Fail as "re-link", recording the connection's health on the way out + * so the accounts list shows every stalled connection — not only the + * one that happened to meet the rejection first. */ + const failWithRelink = (message: string): Effect.Effect => + markConnectionUnhealthy(row, message).pipe(Effect.andThen(Effect.fail(relink(message)))); + const held = fromWorkIdentity ? Option.getOrNull(decodeWorkIdentityRecord(stored)) : null; + if (fromWorkIdentity && held === null) { + return yield* failWithRelink( + "Your enterprise work identity is missing or unreadable. Link your work identity again to restore this connection.", + ); + } + if (held !== null && !isWorkIdentityUsable(held)) { + // Already known dead. Short-circuit BEFORE the exchange: the IdP has + // already given its verdict on this subject, and re-spending it once + // per connection per resolve is precisely the hammering the connection + // -level known-dead gate exists to prevent. + yield* Effect.annotateCurrentSpan({ + "executor.oauth.refresh.skipped_dead_work_identity": true, + }); + return yield* failWithRelink( + "Your enterprise work identity was rejected by the identity provider. Link your work identity again to restore this connection.", + ); + } + const subjectToken = held === null ? stored : held.token; + const subjectTokenType = held === null ? state.subjectTokenType : held.tokenType; const idpClientSecret = idpRow.client_secret_item_id ? ((yield* provider.get(ProviderItemId.make(String(idpRow.client_secret_item_id)))) ?? "") : ""; @@ -1966,7 +2065,7 @@ export const createExecutor = - Effect.fail( - new CredentialResolutionError({ - owner, - integration: IntegrationSlug.make(row.integration), - name: ConnectionName.make(row.name), - message: enterpriseManagedMessage(cause), - reauthRequired: true, - }), - ), + fromWorkIdentity + ? markWorkIdentityRejected(provider, subjectItemId).pipe( + Effect.andThen( + Effect.fail( + relink( + `${enterpriseManagedMessage(cause)} Link your work identity again to restore this connection.`, + ), + ), + ), + ) + : Effect.fail( + new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: enterpriseManagedMessage(cause), + reauthRequired: true, + }), + ), EmaRedemptionRejected: (cause) => Effect.fail( new CredentialResolutionError({ @@ -2014,12 +2127,19 @@ export const createExecutor = Effect.fail(new StorageError({ message: enterpriseManagedMessage(cause), cause })), }), - Effect.tapError((error) => - Predicate.isTagged(error, "CredentialResolutionError") && error.reauthRequired === true - ? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field - markRefreshGrantDead(row, error.message) - : Effect.void, - ), + Effect.tapError((error) => { + if (!Predicate.isTagged(error, "CredentialResolutionError")) return Effect.void; + if (error.reauthRequired !== true) return Effect.void; + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field + const detail = error.message; + // A dead WORK IDENTITY must not stamp the connection's reauth + // verdict: that verdict is cleared only by a reconnect mint, and + // the recovery here is a single re-link that touches no connection. + // Health still records it, so nothing goes quiet. + return error.workIdentityRelinkRequired === true + ? markConnectionUnhealthy(row, detail) + : markRefreshGrantDead(row, detail); + }), ); // Draft §4.4.3: the Resource Authorization Server SHOULD NOT issue a diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index a9b5aeb1f5..6e3b06637f 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -315,8 +315,30 @@ export { type OAuthProbeInput, type OAuthProbeResult, type OAuthService, + type OAuthCallbackCompletion, + type WorkIdentityLinkStart, } from "./oauth-client"; +// Work identity — how the enterprise assertion the EMA connect consumes is +// acquired and held. Contracts + the persisted record's schema; the flow itself +// lives in the OAuth service. +export { + DEFAULT_WORK_IDENTITY_SCOPES, + WORK_IDENTITY_SESSION_SENTINEL, + WorkIdentityLinkError, + WorkIdentityRefSchema, + WorkIdentityRevocationReasonSchema, + WorkIdentityStatusSchema, + workIdentityItemId, + workIdentityStatusOf, + type CompleteWorkIdentityLinkInput, + type StartWorkIdentityLinkInput, + type WorkIdentityRecord, + type WorkIdentityRef, + type WorkIdentityRevocationReason, + type WorkIdentityStatus, +} from "./oauth-work-identity"; + // The enterprise-managed rollout PORT (not its implementation): hosts that // operate a feature-flag service implement this and hand it to // `createExecutor`. Core depends on no vendor. diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 2cbbbd9110..cc296d247f 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -12,6 +12,13 @@ import { OAuthState, Owner, } from "./ids"; +import type { + CompleteWorkIdentityLinkInput, + StartWorkIdentityLinkInput, + WorkIdentityLinkError, + WorkIdentityRef, + WorkIdentityStatus, +} from "./oauth-work-identity"; /** RFC 8693 §3 security token type identifiers usable as a `subject_token_type` * when exchanging an enterprise identity assertion for an ID-JAG. The id-jag @@ -277,9 +284,19 @@ export interface OAuthStartInput { } /** What an enterprise-managed connect needs beyond the ordinary start inputs. - * The subject token is supplied by the caller because "where the identity - * assertion comes from" is a host concern: a desktop app holds its own SSO - * tokens, a hosted deployment holds the session's. + * + * Two ways to say who is connecting, and the difference is custody: + * + * - OMIT `subjectToken` (the console's path). The server resolves the user's + * HELD work identity for this IdP app — the durable refresh-token subject a + * `oauth.startWorkIdentityLink` put in custody. The minted connection points + * at that shared record, so renewals outlive any ID token and one re-link + * revives every connection made this way. Not linked yet → the start fails + * with `workIdentityLinkRequired`, which is the console's cue to link. + * - SUPPLY `subjectToken` (the API/headless path). The caller is the source of + * the assertion, exactly as before: a desktop app holding its own SSO + * tokens, or a script. The connection keeps a private copy of what was + * passed, and its lifetime is whatever that token's lifetime is. * * Declared as a Schema because this shape crosses the HTTP boundary: the API's * `oauth.start` payload embeds THIS schema rather than restating its fields. */ @@ -287,19 +304,31 @@ export const EnterpriseManagedStartInputSchema = Schema.Struct({ /** `oauth_client` slug of the client's registration at the enterprise IdP. */ idpClient: OAuthClientSlug, idpClientOwner: Owner, - /** The identity assertion from single sign-on with the IdP (an OIDC ID token - * by default). Persisted through the credential provider so token renewal - * needs no further user interaction. */ - subjectToken: Schema.String, - /** RFC 8693 §3 type of `subjectToken`. Defaults to an OIDC ID token. */ + /** The identity assertion from single sign-on with the IdP. OMIT to use the + * work identity the user already linked for `idpClient`; supply one to make + * the caller the source, in which case the connection takes custody of + * exactly this value. */ + subjectToken: Schema.optional(Schema.String), + /** RFC 8693 §3 type of `subjectToken`. Read only alongside an explicit + * `subjectToken`; a resolved work identity carries its own type (its custody + * decides it, not the caller). Defaults to an OIDC ID token. */ subjectTokenType: Schema.optional(SubjectTokenTypeSchema), }).annotate({ identifier: "EnterpriseManagedStartInput", description: - "The second client registration (at the enterprise identity provider) and the identity assertion an enterprise-managed connect presents.", + "The second client registration (at the enterprise identity provider) and, optionally, the identity assertion an enterprise-managed connect presents. Omitted, the caller's linked work identity is used.", }); export type EnterpriseManagedStartInput = typeof EnterpriseManagedStartInputSchema.Type; +/** What one OAuth callback turned out to be. The redirect URI is shared by both + * browser flows executor runs — connecting an integration, and linking a work + * identity — so the callback edge cannot know which it received until the + * session says. The IN-FLIGHT SESSION is that answer; nothing is inferred from + * the URL. */ +export type OAuthCallbackCompletion = + | { readonly kind: "connection"; readonly connection: Connection } + | { readonly kind: "work-identity"; readonly workIdentity: WorkIdentityStatus }; + export interface OAuthCompleteInput { readonly state: OAuthState; readonly code: string; @@ -377,6 +406,13 @@ export class OAuthStartError * token-endpoint refusal. A typed field rather than message text so * telemetry and support tooling read the verdict structurally. */ oauthErrorCode: Schema.optional(Schema.String), + /** True when this enterprise-managed connect could not proceed because the + * user holds no usable work identity for the named IdP app — never linked, + * or linked and since rejected. The remedy is a work-identity LINK, not a + * retry and not the interactive per-server flow, so a console branches on + * this field to send the user to the right place. Never set together with + * `blockedByAdmin`: nothing was asked of the IdP. */ + workIdentityLinkRequired: Schema.optional(Schema.Boolean), }) implements UserActionableError { @@ -467,8 +503,63 @@ export interface OAuthService { readonly complete: ( input: OAuthCompleteInput, ) => Effect.Effect; + /** Complete whatever flow this callback belongs to, as decided by the stored + * session. The HTTP callback route calls THIS; `complete` and + * `completeWorkIdentityLink` remain the direct, single-purpose entry points + * for callers that already know which flow they started, and each refuses a + * state belonging to the other. */ + readonly completeCallback: ( + input: OAuthCompleteInput, + ) => Effect.Effect< + OAuthCallbackCompletion, + OAuthCompleteError | WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure + >; readonly cancel: (state: OAuthState) => Effect.Effect; readonly probe: ( input: OAuthProbeInput, ) => Effect.Effect; + + // ------------------------------------------------------------------------- + // Work identity — acquiring the enterprise assertion `start` consumes. + // + // A separate flow from `start`/`complete` on purpose. It mints NO connection + // and touches no integration: it links a PERSON to an enterprise IdP app, once, + // and every enterprise-managed connect for that app afterwards resolves it. + // Its callback is its own route for the same reason — a link redirect must not + // be able to arrive at a handler that would try to mint a connection from it. + // ------------------------------------------------------------------------- + + /** Begin linking the caller's enterprise identity: build the authorization URL + * for the registered IdP app and persist the in-flight session. Returns the + * URL to visit and the state that identifies the flow. */ + readonly startWorkIdentityLink: ( + input: StartWorkIdentityLinkInput, + ) => Effect.Effect; + /** Redeem a link's authorization code with the IdP app's own credentials and + * take custody of the durable subject. Returns the resulting status, so the + * caller that completes the flow already knows which account was linked. */ + readonly completeWorkIdentityLink: ( + input: CompleteWorkIdentityLinkInput, + ) => Effect.Effect< + WorkIdentityStatus, + WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure + >; + /** Whether a usable enterprise identity is held for this (owner, IdP app), and + * which account it is. The console's poll; never returns credential material. */ + readonly workIdentityStatus: ( + ref: WorkIdentityRef, + ) => Effect.Effect; + /** Drop a held identity. Idempotent. Enterprise-managed connections backed by + * it stop renewing and report that a link is required — they are NOT removed, + * because linking again revives them. */ + readonly unlinkWorkIdentity: (ref: WorkIdentityRef) => Effect.Effect; +} + +/** Where to send the user to link their enterprise identity, and the state that + * identifies the flow on the way back. Mirrors `ConnectResult`'s redirect arm — + * there is no "connected" counterpart, because a link ALWAYS requires the + * user's browser to visit the IdP. */ +export interface WorkIdentityLinkStart { + readonly authorizationUrl: string; + readonly state: OAuthState; } diff --git a/packages/core/sdk/src/oauth-ema.ts b/packages/core/sdk/src/oauth-ema.ts index 8cc11bb01f..2a4f101f35 100644 --- a/packages/core/sdk/src/oauth-ema.ts +++ b/packages/core/sdk/src/oauth-ema.ts @@ -64,6 +64,22 @@ export const EnterpriseManagedConnectionStateSchema = Schema.Struct({ * its RFC 8414 metadata when the connection was made. */ audience: Schema.String, subjectTokenType: SubjectTokenTypeSchema, + /** Where the subject token in `refresh_item_id` came from, which decides who + * OWNS it and therefore what "it died" means: + * + * - `caller` (the default for a connection minted before work identities + * existed, and for every headless caller that passes its own assertion): + * the connection holds a private copy. A rejection kills THIS connection + * and only a reconnect carrying a fresh assertion revives it. + * - `work-identity`: `refresh_item_id` points at the user's shared work + * identity record (`work-identity:…`), not a private copy. A rejection + * kills the IDENTITY — every connection pointing at it is stalled, and one + * re-link revives them all, so nothing may be recorded on the connection + * that a reconnect would have to clear. + * + * Optional because rows written before this existed carry none, and their + * custody is by construction the `caller` shape. */ + subjectSource: Schema.optional(Schema.Literals(["caller", "work-identity"])), }).annotate({ identifier: "EnterpriseManagedConnectionState", description: diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 68622f2546..2def6fbdba 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -48,6 +48,19 @@ export type OAuth2TokenResponse = { readonly expires_in?: number; readonly scope?: string; readonly idTokenIdentityLabel?: string; + /** + * The raw OIDC ID token the endpoint returned, when it returned one. + * + * Named in camelCase deliberately: this is NOT the wire field (that one is + * stripped before oauth4webapi validates the response — see `stripIdToken`), + * so nothing can re-serialize this object as an RFC 6749 token response and + * accidentally re-emit it. + * + * It exists for ONE caller: the enterprise work-identity link, which takes + * custody of the ID token when the IdP issues no refresh token. The ordinary + * connection mint reads named fields and never persists this. + */ + readonly idToken?: string; }; // --------------------------------------------------------------------------- @@ -720,9 +733,33 @@ export const idTokenIdentityLabel = (idToken: string | undefined): string | unde ); }; +/** The account an ID token names, as far as a display and a deadline go. + * + * Read UNVERIFIED, and only ever used that way: these claims describe a token + * we already hold from a token endpoint we just authenticated to, so they are + * provenance for the user's benefit, never an authorization input. `expiresAt` + * is epoch MILLIseconds (`exp` is seconds on the wire). */ +export const idTokenAccountFacts = ( + idToken: string | undefined, +): { + readonly subject: string | null; + readonly label: string | null; + readonly expiresAt: number | null; +} => { + const claims = idToken === undefined ? null : decodeJwtPayload(idToken); + if (claims === null) return { subject: null, label: null, expiresAt: null }; + const exp = claims.exp; + return { + subject: stringClaim(claims, "sub") ?? null, + label: idTokenIdentityLabel(idToken) ?? null, + expiresAt: typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null, + }; +}; + type StrippedTokenResponse = { readonly response: Response; readonly idTokenIdentityLabel?: string; + readonly idToken?: string; }; const NestedAuthedUserScope = Schema.Struct({ @@ -791,6 +828,7 @@ const stripIdToken = async (response: Response): Promise headers: response.headers, }), ...(label ? { idTokenIdentityLabel: label } : {}), + ...(typeof idToken === "string" && idToken.length > 0 ? { idToken } : {}), }; }; @@ -817,9 +855,13 @@ const processTokenEndpointResponse = async ( scope: providerUserGrant.scope, } : parsed; - return stripped.idTokenIdentityLabel - ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel } - : token; + return { + ...token, + ...(stripped.idTokenIdentityLabel === undefined + ? {} + : { idTokenIdentityLabel: stripped.idTokenIdentityLabel }), + ...(stripped.idToken === undefined ? {} : { idToken: stripped.idToken }), + }; }; // --------------------------------------------------------------------------- diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 7515799794..7c800d5a98 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -44,6 +44,8 @@ import { type CreateOAuthClientInput, type EnterpriseManagedStartInput, type FirstPartyOAuthClientConfig, + type OAuthCallbackCompletion, + type WorkIdentityLinkStart, type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, @@ -75,6 +77,23 @@ import { type EnterpriseManagedRolloutDecision, type EnterpriseManagedRolloutEvent, } from "./oauth-ema"; +import { + DEFAULT_WORK_IDENTITY_SCOPES, + WORK_IDENTITY_SESSION_SENTINEL, + WorkIdentityLinkError, + decodeWorkIdentityRecord, + encodeWorkIdentityRecord, + isWorkIdentityUsable, + workIdentityCustodyType, + workIdentityItemId, + workIdentitySessionPayloadFrom, + workIdentityStatusOf, + type CompleteWorkIdentityLinkInput, + type StartWorkIdentityLinkInput, + type WorkIdentityRecord, + type WorkIdentityRef, + type WorkIdentityStatus, +} from "./oauth-work-identity"; import { assertSupportedOAuthEndpointUrl, buildAuthorizationUrl, @@ -84,6 +103,7 @@ import { createPkceCodeVerifier, exchangeAuthorizationCode, exchangeClientCredentials, + idTokenAccountFacts, isLoopbackHttpUrl, rebindTokenEndpointHostToCallbackDomain, type OAuth2TokenResponse, @@ -343,16 +363,25 @@ export const missingGrantedOAuthScopes = ( const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); -/** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The - * jsonColumn may surface as a parsed object (in-memory backends) or a JSON - * string (serialized backends); decode strings before reading. Returns `null` - * for legacy sessions written before `requestedScopes` was persisted, so - * `complete` can fall back to the client's scopes. */ +/** The stored `oauth_session` row, as this module reads it. `looseDb` returns an + * untyped record; naming the columns we touch keeps the reads honest without + * claiming a decoded row. */ +type SessionRow = Readonly>; + +/** An `oauth_session.payload` as a value, whichever way the backend surfaced it. + * The jsonColumn is a parsed object on in-memory backends and a JSON string on + * serialized ones; every payload reader starts here so that difference is + * handled exactly once. */ +const sessionPayload = (payload: unknown): unknown => + typeof payload === "string" + ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) + : payload; + +/** Extract the persisted `requestedScopes` from an `oauth_session.payload`. + * Returns `null` for legacy sessions written before `requestedScopes` was + * persisted, so `complete` can fall back to the client's scopes. */ const requestedScopesFromPayload = (payload: unknown): readonly string[] | null => { - const decoded = - typeof payload === "string" - ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) - : payload; + const decoded = sessionPayload(payload); if (decoded === null || typeof decoded !== "object") return null; const value = (decoded as Record).requestedScopes; return Array.isArray(value) ? value.filter((s): s is string => typeof s === "string") : null; @@ -362,10 +391,7 @@ const requestedScopesFromPayload = (payload: unknown): readonly string[] | null * (same-owner connects, or sessions written before this field), so `complete` * falls back to the session owner. */ const clientOwnerFromPayload = (payload: unknown): Owner | null => { - const decoded = - typeof payload === "string" - ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) - : payload; + const decoded = sessionPayload(payload); if (decoded === null || typeof decoded !== "object") return null; const value = (decoded as Record).clientOwner; return value === "user" || value === "org" ? value : null; @@ -1292,6 +1318,353 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ); }; + // ----------------------------------------------------------------------- + // Work identity — linking the enterprise assertion the EMA connect consumes. + // + // Everything here runs on the SAME machinery as the interactive connect flow: + // one `oauth_session` row (same table, same TTL, same cleanup), one + // `buildAuthorizationUrl`, one `exchangeAuthorizationCode`, one callback + // route. What differs is only what the redeemed grant becomes — custody of a + // durable subject token instead of a connection. + // ----------------------------------------------------------------------- + + const loadSessionRow = (state: OAuthState): Effect.Effect => + deps.fuma + .use("oauth_session.findFirst", (db) => + looseDb(db).findFirst("oauth_session", { + where: (b: any) => b("state", "=", String(state)), + }), + ) + .pipe(Effect.map((row) => row as SessionRow | null)); + + /** The default writable store paired with its `set`, or a loud failure. Work + * identities are credential material; there is no degraded mode where they + * live elsewhere. + * + * Returns a PAIR rather than a narrowed provider so the provider object is + * passed through untouched — copying it to re-type `set` would silently drop + * anything a backend carries on a prototype. */ + const requireWritableProvider = ( + purpose: string, + ): Effect.Effect< + { + readonly provider: CredentialProvider; + readonly set: NonNullable; + }, + StorageFailure + > => { + const provider = deps.defaultWritableProvider(); + const set = provider?.set; + if (!provider || set === undefined) { + return Effect.fail( + new StorageError({ + message: `No default writable credential provider is registered to ${purpose}.`, + cause: undefined, + }), + ); + } + return Effect.succeed({ provider, set }); + }; + + /** Read the held record, or null when nothing usable is stored. A value that + * does not decode reads as ABSENT: the product then offers a link, which + * overwrites it — the one action that both tells the truth ("nothing usable + * is held") and repairs the state. */ + const loadWorkIdentity = ( + ref: WorkIdentityRef, + ): Effect.Effect => + Effect.gen(function* () { + const provider = deps.defaultWritableProvider(); + if (!provider) return null; + const stored = yield* provider.get(workIdentityItemId(ref)); + if (stored === null) return null; + return Option.getOrNull(decodeWorkIdentityRecord(stored)); + }); + + const storeWorkIdentity = ( + ref: WorkIdentityRef, + record: WorkIdentityRecord, + ): Effect.Effect => + requireWritableProvider("store the enterprise work identity").pipe( + Effect.flatMap(({ set }) => set(workIdentityItemId(ref), encodeWorkIdentityRecord(record))), + ); + + const startWorkIdentityLink = ( + input: StartWorkIdentityLinkInput, + ): Effect.Effect => + Effect.gen(function* () { + const keys = yield* Effect.try({ + try: () => deps.ownedKeys(input.owner), + catch: (cause) => + new StorageError({ + message: "Cannot link a work identity for an owner without a subject", + cause, + }), + }); + const client = yield* loadClient(input.idpClientOwner, input.idpClient); + if (!client) { + return yield* new WorkIdentityLinkError({ + message: `Enterprise identity provider OAuth client not found: ${input.idpClient}`, + }); + } + // The link RUNS this app's authorization-code flow. An app registered for + // any other grant cannot serve one, and finding that out at the IdP's + // authorize endpoint would surface as an opaque provider error page. + if (client.grant !== "authorization_code") { + return yield* new WorkIdentityLinkError({ + message: `OAuth app "${input.idpClient}" uses the ${client.grant} grant; linking a work identity runs the authorization-code flow, so the enterprise identity provider's app must be registered for it.`, + }); + } + const flowRedirectUri = input.redirectUri ?? redirectUri; + if (flowRedirectUri == null) { + return yield* new WorkIdentityLinkError({ message: REDIRECT_URI_REQUIRED_MESSAGE }); + } + const requestedScopes = dedupeScopes(input.scopes ?? DEFAULT_WORK_IDENTITY_SCOPES); + + const verifier = createPkceCodeVerifier(); + const challenge = yield* Effect.promise(() => createPkceCodeChallenge(verifier)); + const state = OAuthState.make(createOAuthState()); + const providerState = encodeOAuthCallbackState({ + state: String(state), + orgSlug: deps.callbackStateOrgSlug, + }); + + yield* deps.fuma.use("oauth_session.create", (db) => + looseDb(db).create("oauth_session", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + state: String(state), + client_slug: String(input.idpClient), + // No integration, connection or template is involved in a link. The + // sentinel says so explicitly; the payload below is what completion reads. + integration: WORK_IDENTITY_SESSION_SENTINEL, + name: WORK_IDENTITY_SESSION_SENTINEL, + template: WORK_IDENTITY_SESSION_SENTINEL, + redirect_url: flowRedirectUri, + pkce_verifier: verifier, + identity_label: null, + payload: { + kind: WORK_IDENTITY_SESSION_SENTINEL, + owner: input.owner, + idpClient: String(input.idpClient), + idpClientOwner: input.idpClientOwner, + requestedScopes, + }, + expires_at: Date.now() + OAUTH2_SESSION_TTL_MS, + created_at: new Date(), + }), + ); + + const authorizationUrl = yield* Effect.try({ + try: () => + buildAuthorizationUrl({ + authorizationUrl: client.authorizationUrl, + clientId: client.clientId, + redirectUrl: flowRedirectUri, + scopes: requestedScopes, + state: providerState, + codeChallenge: challenge, + resource: client.resource ?? undefined, + // The same provider quirks the connect flow needs, for the same + // reason: without Google's `access_type=offline` there is no refresh + // token, and a work identity with no refresh token is exactly the + // hour-long custody this feature exists to avoid. + extraParams: providerAuthorizeExtras(client.authorizationUrl), + endpointUrlPolicy: deps.endpointUrlPolicy, + }), + catch: (cause) => + new WorkIdentityLinkError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: surface the URL-construction failure + message: `Failed to build the work identity authorization URL: ${String(cause)}`, + }), + }); + + return { authorizationUrl, state } satisfies WorkIdentityLinkStart; + }).pipe( + Effect.withSpan("executor.oauth.work_identity.start", { + attributes: { + "executor.tenant": deps.tenant, + "executor.oauth.client": String(input.idpClient), + }, + }), + ); + + const completeWorkIdentityLink = ( + input: CompleteWorkIdentityLinkInput, + ): Effect.Effect< + WorkIdentityStatus, + WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure + > => + Effect.gen(function* () { + const sessionRow = yield* loadSessionRow(input.state); + if (!sessionRow) return yield* new OAuthSessionNotFoundError({ state: input.state }); + const link = workIdentitySessionPayloadFrom(sessionPayload(sessionRow.payload)); + if (link === null) { + // The state belongs to a CONNECT. Refusing here is what stops a link + // completion from redeeming someone else's authorization code into + // identity custody, and it is checked rather than assumed even though + // the callback edge already routes by session kind. + return yield* new WorkIdentityLinkError({ + message: `OAuth state ${input.state} does not belong to a work identity link.`, + restartRequired: true, + }); + } + const expiresAt = Number(sessionRow.expires_at); + if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) { + yield* deleteSession(input.state); + return yield* new OAuthSessionNotFoundError({ state: input.state }); + } + const verifier = sessionRow.pkce_verifier == null ? null : String(sessionRow.pkce_verifier); + if (verifier === null) { + return yield* new WorkIdentityLinkError({ + message: `Work identity link ${input.state} is missing its PKCE code verifier; start the link again.`, + restartRequired: true, + }); + } + const client = yield* loadClient(link.idpClientOwner, link.idpClient); + if (!client) { + return yield* new WorkIdentityLinkError({ + message: `Enterprise identity provider OAuth client not found: ${link.idpClient}`, + restartRequired: true, + }); + } + + const token = yield* exchangeAuthorizationCode({ + tokenUrl: client.tokenUrl, + clientId: client.clientId, + clientSecret: client.clientSecret, + redirectUrl: String(sessionRow.redirect_url), + codeVerifier: verifier, + code: input.code, + resource: client.resource ?? undefined, + endpointUrlPolicy: deps.endpointUrlPolicy, + fetch, + }).pipe( + Effect.mapError( + (cause) => + new WorkIdentityLinkError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message` field + message: `The enterprise identity provider rejected the sign-in: ${cause.message}`, + restartRequired: cause.error === "invalid_grant", + }), + ), + ); + + // §4.5: the refresh token is the durable subject and is preferred whenever + // the IdP issued one. ID-token custody is the recorded degradation for an + // IdP that issues none — it is stored with its own type and its `exp`, so + // the product can say when it dies instead of discovering it at renewal. + const tokenType = workIdentityCustodyType({ + refreshToken: token.refresh_token, + idToken: token.idToken, + }); + if (tokenType === null) { + return yield* new WorkIdentityLinkError({ + message: + "The enterprise identity provider returned neither a refresh token nor an ID token, so there is nothing durable to hold. Request `openid` and `offline_access` on the link, or check that the identity provider's app is allowed to issue refresh tokens.", + }); + } + const facts = idTokenAccountFacts(token.idToken); + const isRefreshCustody = tokenType === "urn:ietf:params:oauth:token-type:refresh_token"; + const ref: WorkIdentityRef = { + owner: link.owner, + idpClient: link.idpClient, + idpClientOwner: link.idpClientOwner, + }; + const record: WorkIdentityRecord = { + // SAFETY-adjacent: `workIdentityCustodyType` already established which + // of the two is present, so the branch below cannot read an absent one. + token: isRefreshCustody ? (token.refresh_token ?? "") : (token.idToken ?? ""), + tokenType, + subject: facts.subject, + label: facts.label ?? token.idTokenIdentityLabel ?? null, + linkedAt: Date.now(), + // A refresh token has no client-visible expiry; that is the property + // this whole design is buying. + expiresAt: isRefreshCustody ? null : facts.expiresAt, + // What the IdP said it granted, falling back to what was asked for when + // it said nothing. "No scope at all" is null, not an empty string. + scope: token.scope ?? (link.requestedScopes.join(" ") || null), + }; + yield* storeWorkIdentity(ref, record); + yield* deleteSession(input.state); + + // Enumerable facts only: which custody was taken and whether the account + // could be named. Never the token, never the account identifier itself. + yield* Effect.annotateCurrentSpan({ + "executor.oauth.work_identity.custody": isRefreshCustody ? "refresh_token" : "id_token", + "executor.oauth.work_identity.has_account_claims": facts.subject !== null, + }); + return workIdentityStatusOf(ref, record); + }).pipe( + Effect.withSpan("executor.oauth.work_identity.complete", { + attributes: { + "executor.tenant": deps.tenant, + ...(deps.subject != null ? { "executor.subject": deps.subject } : {}), + }, + }), + ); + + const workIdentityStatus = ( + ref: WorkIdentityRef, + ): Effect.Effect => + loadWorkIdentity(ref).pipe(Effect.map((record) => workIdentityStatusOf(ref, record))); + + const unlinkWorkIdentity = (ref: WorkIdentityRef): Effect.Effect => { + const provider = deps.defaultWritableProvider(); + // Idempotent by construction: a store that cannot delete, or an item that is + // already gone, both leave the caller with "nothing is held", which is the + // outcome asked for. + return provider?.delete === undefined ? Effect.void : provider.delete(workIdentityItemId(ref)); + }; + + /** Which subject token an enterprise-managed connect will present, and who + * owns it. The ONE place the two custody models diverge; every caller below + * works off the result rather than re-deciding. */ + const resolveEnterpriseSubject = ( + owner: Owner, + enterprise: EnterpriseManagedStartInput, + ): Effect.Effect< + { + readonly token: string; + readonly tokenType: SubjectTokenType; + readonly source: "caller" | "work-identity"; + }, + OAuthStartError | StorageFailure + > => + Effect.gen(function* () { + const supplied = enterprise.subjectToken; + if (supplied !== undefined && supplied.length > 0) { + return { + token: supplied, + tokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, + source: "caller" as const, + }; + } + const ref: WorkIdentityRef = { + owner, + idpClient: enterprise.idpClient, + idpClientOwner: enterprise.idpClientOwner, + }; + const record = yield* loadWorkIdentity(ref); + if (record === null) { + return yield* new OAuthStartError({ + message: + "No enterprise work identity is linked for this identity provider. Link your work identity, then connect again.", + workIdentityLinkRequired: true, + }); + } + if (!isWorkIdentityUsable(record)) { + return yield* new OAuthStartError({ + message: + "Your enterprise work identity was rejected by the identity provider. Link it again, then connect.", + workIdentityLinkRequired: true, + }); + } + return { token: record.token, tokenType: record.tokenType, source: "work-identity" as const }; + }); + // ----------------------------------------------------------------------- // start — begin a flow through a client to mint a connection. // ----------------------------------------------------------------------- @@ -1511,13 +1884,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ), ); - // Resolve the caller's optional assertion type ONCE: the chain sends - // it and the connection persists it, and those two must not be able - // to disagree about what was presented. - const resolvedEnterprise = { - ...enterprise, - subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE, - }; + // Resolve WHICH subject token is presented — and whose it is — ONCE: + // the chain sends it and the connection persists custody of it, and + // those two must not be able to disagree about what was presented. + const subject = yield* resolveEnterpriseSubject(input.owner, enterprise); const enterpriseGrant = yield* runEnterpriseManagedAuthorization({ authorizationServerMetadata: metadata, idp: { @@ -1529,8 +1899,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientId: client.clientId, clientSecret: client.clientSecret, }, - subjectToken: resolvedEnterprise.subjectToken, - subjectTokenType: resolvedEnterprise.subjectTokenType, + subjectToken: subject.token, + subjectTokenType: subject.tokenType, resource: client.resource, scopes: requestedScopes, endpointUrlPolicy: deps.endpointUrlPolicy, @@ -1566,7 +1936,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { client, input.clientOwner, enterpriseGrant.grant, - resolvedEnterprise, + { idpClient: enterprise.idpClient, idpClientOwner: enterprise.idpClientOwner }, + subject, metadata.issuer, ).pipe( Effect.mapError( @@ -1681,14 +2052,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: OAuthCompleteInput, ): Effect.Effect => Effect.gen(function* () { - const sessionRow = yield* deps.fuma.use("oauth_session.findFirst", (db) => - looseDb(db).findFirst("oauth_session", { - where: (b: any) => b("state", "=", String(input.state)), - }), - ); + const sessionRow = yield* loadSessionRow(input.state); if (!sessionRow) { return yield* new OAuthSessionNotFoundError({ state: input.state }); } + // A work-identity link shares this table and this callback URL. Its + // integration/name/template columns are sentinels, so completing one HERE + // would mint a connection out of placeholder text. Refuse explicitly + // rather than relying on the callback edge having routed correctly. + if (workIdentitySessionPayloadFrom(sessionPayload(sessionRow.payload)) !== null) { + return yield* new OAuthCompleteError({ + message: `OAuth state ${input.state} belongs to a work identity link, not a connection.`, + restartRequired: true, + }); + } const session = { owner: String(sessionRow.owner) as Owner, clientSlug: OAuthClientSlug.make(String(sessionRow.client_slug)), @@ -1936,9 +2313,19 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { /** Mint a connection from an enterprise-managed grant. Distinct from * `mintFromToken` because the material persisted is different: there is no - * refresh token (draft §4.4.3), and the identity assertion takes the refresh - * slot — it is exactly the credential that lets renewal run without the - * user, which is what that slot means. */ + * refresh token (draft §4.4.3), and the SUBJECT TOKEN takes the refresh slot + * — it is exactly the credential that lets renewal run without the user, + * which is what that slot means. + * + * WHERE that slot points is the whole custody decision: + * + * - a CALLER-supplied assertion is copied into the connection's own + * `:refresh` item. The connection owns it, and nothing else can revive it. + * - a WORK IDENTITY is not copied at all: the slot points at the shared + * `work-identity:…` record. N connections, one item — which is what makes + * one re-link revive all of them, and what makes "this identity died" + * expressible as a fact about the identity instead of N facts about N + * connections. */ const mintEnterpriseManagedConnection = ( target: { readonly owner: Owner; @@ -1950,30 +2337,45 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { client: LoadedOAuthClient, clientOwner: Owner, grant: EnterpriseManagedGrant, - /** The connect request's enterprise inputs with the assertion type already - * resolved — the persisted state records what was actually presented, so - * it must not re-derive a default the chain might have differed on. */ - enterprise: EnterpriseManagedStartInput & { readonly subjectTokenType: SubjectTokenType }, + /** Which IdP registration minted the ID-JAG, as named on the connect. */ + idp: Pick, + /** The subject actually presented, with its type and its custody already + * decided — the persisted state records what happened, and must not + * re-derive a default the chain might have differed on. */ + subject: { + readonly token: string; + readonly tokenType: SubjectTokenType; + readonly source: "caller" | "work-identity"; + }, /** The Resource Authorization Server's issuer identifier, as discovered. */ audience: string, ): Effect.Effect => Effect.gen(function* () { - const provider = deps.defaultWritableProvider(); - if (!provider || !provider.set) { - return yield* new StorageError({ - message: - "No default writable credential provider is registered to store the OAuth access token.", - cause: undefined, - }); - } + const { provider, set } = yield* requireWritableProvider("store the OAuth access token"); const itemId = accessItemId(target.owner, target.integration, target.name); - yield* provider.set(ProviderItemId.make(itemId), grant.token.access_token); - const subjectTokenItemId = refreshItemIdFor(itemId); - yield* provider.set(ProviderItemId.make(subjectTokenItemId), enterprise.subjectToken); + yield* set(ProviderItemId.make(itemId), grant.token.access_token); + + const subjectTokenItemId = + subject.source === "work-identity" + ? String( + workIdentityItemId({ + owner: target.owner, + idpClient: idp.idpClient, + idpClientOwner: idp.idpClientOwner, + }), + ) + : refreshItemIdFor(itemId); + // Only a caller-supplied assertion is written here. Re-writing the work + // identity would replace a record (the durable subject plus its account + // facts and any rejection) with a bare token string. + if (subject.source === "caller") { + yield* set(ProviderItemId.make(subjectTokenItemId), subject.token); + } yield* Effect.annotateCurrentSpan({ "executor.oauth.has_advertised_expiry": typeof grant.token.expires_in === "number", "executor.oauth.enterprise_managed": true, + "executor.oauth.enterprise_subject_source": subject.source, }); return yield* deps.mintOAuthConnection({ owner: target.owner, @@ -1990,14 +2392,42 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { expiresAt: expiresAtFrom(grant.token), oauthScope: grant.scope, enterpriseManaged: { - idpClient: enterprise.idpClient, - idpClientOwner: enterprise.idpClientOwner, + idpClient: idp.idpClient, + idpClientOwner: idp.idpClientOwner, audience, - subjectTokenType: enterprise.subjectTokenType, + subjectTokenType: subject.tokenType, + subjectSource: subject.source, }, }); }); + // ----------------------------------------------------------------------- + // completeCallback — finish whichever flow this callback belongs to. + // + // Both browser flows land on the SAME redirect URI, because an enterprise + // registers executor's callback with its identity provider once and a second + // URL would be a second thing to get wrong. The session row is therefore the + // only trustworthy answer to "what is this?" — not the URL, and not the state + // envelope (which degrades to the raw state on hosts that route no org slug). + // ----------------------------------------------------------------------- + const completeCallback = ( + input: OAuthCompleteInput, + ): Effect.Effect< + OAuthCallbackCompletion, + OAuthCompleteError | WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure + > => + Effect.gen(function* () { + const sessionRow = yield* loadSessionRow(input.state); + if (!sessionRow) return yield* new OAuthSessionNotFoundError({ state: input.state }); + return workIdentitySessionPayloadFrom(sessionPayload(sessionRow.payload)) === null + ? yield* complete(input).pipe( + Effect.map((connection) => ({ kind: "connection" as const, connection })), + ) + : yield* completeWorkIdentityLink({ state: input.state, code: input.code }).pipe( + Effect.map((workIdentity) => ({ kind: "work-identity" as const, workIdentity })), + ); + }); + const deleteSession = (state: OAuthState): Effect.Effect => deps.fuma .use("oauth_session.delete", (db) => @@ -2069,7 +2499,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { listClients, start, complete, + completeCallback, cancel, probe, + startWorkIdentityLink, + completeWorkIdentityLink, + workIdentityStatus, + unlinkWorkIdentity, }; }; diff --git a/packages/core/sdk/src/oauth-work-identity.test.ts b/packages/core/sdk/src/oauth-work-identity.test.ts new file mode 100644 index 0000000000..7079142c65 --- /dev/null +++ b/packages/core/sdk/src/oauth-work-identity.test.ts @@ -0,0 +1,641 @@ +// --------------------------------------------------------------------------- +// Work identity — acquiring, holding and losing the enterprise assertion that +// enterprise-managed authorization consumes. +// +// `oauth-ema-lifecycle.test.ts` covers the caller-supplied-assertion world: the +// connect request carries the assertion, the connection keeps a private copy, +// and when that copy dies the CONNECTION is dead. This file covers the world the +// console lives in, where the product acquires the assertion itself, and where +// the two claims that follow from that are the whole point: +// +// 1. custody is the IdP REFRESH token (draft §4.5), so renewal outlives the +// ID token that would otherwise kill every managed connection within the +// hour, and +// 2. the identity is SHARED by every connection made from it, so its death is +// one fact with one remedy — re-link once — rather than N dead connections +// needing N reconnects. +// +// Both are asserted through the executor's own surfaces and the identity +// provider's request ledger, never by reading private state. +// --------------------------------------------------------------------------- + +import { assert, describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ToolAddress, + ToolName, +} from "./ids"; +import type { OAuthService } from "./oauth-client"; +import { definePlugin } from "./plugin"; +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("oauth"); +const IDP_CLIENT = OAuthClientSlug.make("enterprise-idp"); +const RESOURCE_CLIENT = OAuthClientSlug.make("mcp-server-app"); +const CONNECTION = ConnectionName.make("work"); +const SECOND_CONNECTION = ConnectionName.make("work2"); +const TOOL = ToolAddress.make("tools.acme.org.work.whoami"); +const SECOND_TOOL = ToolAddress.make("tools.acme.org.work2.whoami"); + +const CLIENT_AT_IDP = "client-at-idp"; +const CLIENT_AT_RESOURCE = "client-at-resource"; +const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token" as const; +const ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" as const; + +/** The enterprise account the IdP fixture signs the user in as. `exp` is far + * enough out that nothing here depends on wall-clock drift; the point of the + * ID-token custody tier is the RECORDED deadline, not reaching it. */ +const ID_TOKEN_EXP_SECONDS = Math.floor(Date.now() / 1000) + 3600; +const ACCOUNT_CLAIMS = { + sub: "00u-enterprise-1", + email: "alice@enterprise.test", + exp: ID_TOKEN_EXP_SECONDS, +} as const; + +const oauthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: ["mcp.read"] }, + }, + ], + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), + }), +}))(); + +/** A workspace with its OWN credential store. The store must not be shared: a + * work identity is filed under an id derived only from (owner, IdP app), so a + * store shared across tests would let one test's held identity be resolved by + * the next — which is exactly the sharing the feature relies on in production + * and exactly the wrong thing between test cases. */ +const workspace = () => + makeTestWorkspaceHarness({ plugins: [memoryCredentialsPlugin(), oauthPlugin] as const }); + +interface EnterpriseServers { + readonly idp: OAuthTestServerShape; + readonly resource: OAuthTestServerShape; +} + +const enterpriseServers = ( + options: { + /** Stand in for an identity provider that hands back no refresh token, which + * forces the link onto ID-token custody. */ + readonly issueRefreshToken?: boolean; + readonly resourceTokenExpiresInSeconds?: number; + } = {}, +) => + Effect.gen(function* () { + const idp = yield* serveOAuthTestServer({ + clients: { [CLIENT_AT_IDP]: null }, + scopes: ["mcp.read"], + idTokenClaims: ACCOUNT_CLAIMS, + ...(options.issueRefreshToken === undefined + ? {} + : { issueRefreshToken: options.issueRefreshToken }), + enterpriseIdp: { resourceClientIds: { [CLIENT_AT_IDP]: CLIENT_AT_RESOURCE } }, + }); + const resource = yield* serveOAuthTestServer({ + clients: { [CLIENT_AT_RESOURCE]: null }, + scopes: ["mcp.read"], + ...(options.resourceTokenExpiresInSeconds === undefined + ? {} + : { tokenExpiresInSeconds: options.resourceTokenExpiresInSeconds }), + enterpriseResourceServer: { trustedIdpIssuer: idp.issuerUrl }, + }); + return { idp, resource } satisfies EnterpriseServers; + }); + +const registerClients = (createClient: OAuthService["createClient"], servers: EnterpriseServers) => + Effect.gen(function* () { + yield* createClient({ + owner: "org", + slug: IDP_CLIENT, + authorizationUrl: servers.idp.authorizationEndpoint, + tokenUrl: servers.idp.tokenEndpoint, + grant: "authorization_code", + clientId: CLIENT_AT_IDP, + clientSecret: "", + }); + yield* createClient({ + owner: "org", + slug: RESOURCE_CLIENT, + authorizationUrl: servers.resource.authorizationEndpoint, + tokenUrl: servers.resource.tokenEndpoint, + grant: "id_jag", + clientId: CLIENT_AT_RESOURCE, + clientSecret: "", + resource: servers.resource.mcpResourceUrl, + }); + }); + +const WORK_IDENTITY = { + owner: "org", + idpClient: IDP_CLIENT, + idpClientOwner: "org", +} as const; + +/** Drive the whole link the way a browser does: ask for the authorization URL, + * sign in at the identity provider, hand the code back. No private state is + * touched — everything below observes the result through `workIdentityStatus` + * and through what the identity provider was later asked. */ +const linkWorkIdentity = (oauth: OAuthService, servers: EnterpriseServers) => + Effect.gen(function* () { + const started = yield* oauth.startWorkIdentityLink(WORK_IDENTITY); + const callback = yield* servers.idp.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + return yield* oauth.completeWorkIdentityLink({ + state: started.state, + code: callback.code, + }); + }); + +/** A connect that names the identity provider but carries NO assertion — the + * console's request. Everything about which subject is presented, and who owns + * it, is resolved server-side. */ +const connectWithHeldIdentity = (name: ConnectionName = CONNECTION) => + ({ + owner: "org", + client: RESOURCE_CLIENT, + clientOwner: "org", + name, + integration: INTEG, + template: TEMPLATE, + enterprise: { idpClient: IDP_CLIENT, idpClientOwner: "org" }, + }) as const; + +const tokenExchanges = (servers: EnterpriseServers) => + servers.idp.requests.pipe( + Effect.map((entries) => + entries + .filter((entry) => entry.path === "/token" && entry.body.includes("token-exchange")) + .map((entry) => new URLSearchParams(entry.body)), + ), + ); + +const healthOf = ( + connections: readonly { readonly name: ConnectionName; readonly lastHealth?: unknown }[], + name: ConnectionName, +) => connections.find((entry) => String(entry.name) === String(name))?.lastHealth; + +describe("work identity — linking", () => { + it.effect("takes custody of the identity provider's refresh token, not its ID token", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + expect( + (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status, + "nothing is held before the user links", + ).toBe("unlinked"); + + const linked = yield* linkWorkIdentity(executor.oauth, servers); + + assert(linked.status === "linked"); + expect( + linked.subjectTokenType, + "draft §4.5: the durable subject is the refresh token — an ID token in custody would strand every managed connection at its first expiry", + ).toBe(REFRESH_TOKEN_TYPE); + expect( + linked.expiresAt, + "a refresh token has no client-visible expiry, and claiming one would be a deadline we invented", + ).toBeNull(); + expect(linked.subject, "the account is named from the ID token's claims").toBe( + ACCOUNT_CLAIMS.sub, + ); + expect(linked.label).toBe(ACCOUNT_CLAIMS.email); + expect(linked.idpClient).toBe(IDP_CLIENT); + }), + ), + ); + + it.effect("requests openid and offline_access so both of those facts are obtainable", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY); + + const scope = new URL(started.authorizationUrl).searchParams.get("scope"); + expect( + scope?.split(" ").sort(), + "`openid` is where the account claims come from and `offline_access` is where the refresh token comes from; dropping either quietly costs the link its identity or its durability", + ).toEqual(["offline_access", "openid"]); + expect( + new URL(started.authorizationUrl).searchParams.get("code_challenge_method"), + "the link runs the same PKCE authorization-code flow as every other browser flow here", + ).toBe("S256"); + }), + ), + ); + + it.effect( + "records ID-token custody, with its deadline, when the IdP issues no refresh token", + () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ issueRefreshToken: false }); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const linked = yield* linkWorkIdentity(executor.oauth, servers); + + assert(linked.status === "linked"); + expect( + linked.subjectTokenType, + "custody of an ID token is a real degradation, so it is recorded as a different kind of custody rather than passed off as the durable one", + ).toBe(ID_TOKEN_TYPE); + expect( + linked.expiresAt, + "and it carries the deadline the product needs to warn before renewal starts failing", + ).toBe(ID_TOKEN_EXP_SECONDS * 1000); + }), + ), + ); + + it.effect("forgets a linked identity on unlink", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + yield* linkWorkIdentity(executor.oauth, servers); + + yield* executor.oauth.unlinkWorkIdentity(WORK_IDENTITY); + expect((yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status).toBe("unlinked"); + + yield* executor.oauth.unlinkWorkIdentity(WORK_IDENTITY); + expect( + (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status, + "unlinking twice is the same outcome as unlinking once", + ).toBe("unlinked"); + }), + ), + ); + + it.effect("refuses to link through an app registered for another grant", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const failure = yield* executor.oauth + .startWorkIdentityLink({ ...WORK_IDENTITY, idpClient: RESOURCE_CLIENT }) + .pipe(Effect.flip); + + assert(Predicate.isTagged(failure, "WorkIdentityLinkError")); + expect( + failure.message, + "the id_jag app is the MCP server's registration; running an authorization-code flow through it would fail at the provider with an opaque error page", + ).toContain("id_jag"); + }), + ), + ); + + it.effect("will not let a link's state be completed as a connection", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY); + const callback = yield* servers.idp.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + + const failure = yield* executor.oauth + .complete({ state: started.state, code: callback.code }) + .pipe(Effect.flip); + + assert( + Predicate.isTagged(failure, "OAuthCompleteError"), + "a link session carries sentinel integration/name/template columns; minting a connection out of them would produce a connection named after a placeholder", + ); + expect(failure.restartRequired).toBe(true); + expect( + (yield* executor.connections.list()).length, + "and nothing was minted on the way to refusing", + ).toBe(0); + }), + ), + ); + + it.effect("routes a shared callback to the flow its session belongs to", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY); + const callback = yield* servers.idp.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + + const completion = yield* executor.oauth.completeCallback({ + state: started.state, + code: callback.code, + }); + + assert( + completion.kind === "work-identity", + "both browser flows share one redirect URI, so the in-flight session — not the URL — is what says which one came back", + ); + expect(completion.workIdentity.status).toBe("linked"); + }), + ), + ); +}); + +describe("work identity — enterprise-managed connect", () => { + it.effect("connects with no assertion on the request, presenting the held refresh token", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + yield* linkWorkIdentity(executor.oauth, servers); + + const started = yield* executor.oauth.start(connectWithHeldIdentity()); + + assert( + started.status === "connected", + "a linked user connects an enterprise-managed server with neither an assertion in hand nor a consent screen", + ); + const exchanges = yield* tokenExchanges(servers); + expect(exchanges.length).toBe(1); + expect( + exchanges[0]?.get("subject_token_type"), + "the exchange presents the durable subject the link took custody of", + ).toBe(REFRESH_TOKEN_TYPE); + + const invoked = (yield* executor.execute(TOOL, {})) as { readonly token: string }; + expect( + yield* servers.resource.acceptsAccessToken(invoked.token), + "and the tool call rides the token the chain minted", + ).toBe(true); + }), + ), + ); + + it.effect("tells an unlinked user to link, rather than failing as a credential problem", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + const failure = yield* executor.oauth.start(connectWithHeldIdentity()).pipe(Effect.flip); + + assert(Predicate.isTagged(failure, "OAuthStartError")); + expect( + failure.workIdentityLinkRequired, + "a console decides from this field whether to offer the LINK; it cannot decide that from a sentence, and retrying or offering per-server consent are both the wrong move", + ).toBe(true); + expect( + failure.blockedByAdmin, + "nothing was asked of the identity provider, so no administrator refused anything", + ).toBeUndefined(); + expect( + (yield* tokenExchanges(servers)).length, + "and no assertion was spent finding that out", + ).toBe(0); + }), + ), + ); + + it.effect("leaves the explicit-assertion path exactly as it was", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + // A headless caller holding its own assertion, with NOTHING linked. + const session = yield* servers.idp.completeAuthorizationCodeTokenFlow({ + clientId: CLIENT_AT_IDP, + clientSecret: "", + scopes: ["mcp.read"], + }); + const started = yield* executor.oauth.start({ + ...connectWithHeldIdentity(), + enterprise: { + idpClient: IDP_CLIENT, + idpClientOwner: "org", + subjectToken: session.accessToken, + subjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + }, + }); + + assert(started.status === "connected"); + const exchanges = yield* tokenExchanges(servers); + expect( + exchanges[0]?.get("subject_token_type"), + "what the caller passed is what is presented — resolution never overrides an explicit assertion", + ).toBe("urn:ietf:params:oauth:token-type:access_token"); + expect( + (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status, + "and a caller-supplied assertion is the caller's, so it never becomes the user's held identity", + ).toBe("unlinked"); + }), + ), + ); +}); + +describe("work identity — rollout gate composability", () => { + it.effect("gates the connect exactly once and does not gate the link at all", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers(); + const consultations: string[] = []; + const { executor } = yield* makeTestWorkspaceHarness({ + plugins: [memoryCredentialsPlugin(), oauthPlugin] as const, + enterpriseManagedRollout: { + decide: (context) => + Effect.sync(() => { + consultations.push(String(context.integration)); + return { kind: "enabled" } as const; + }), + record: () => Effect.void, + }, + }); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + + yield* linkWorkIdentity(executor.oauth, servers); + expect( + consultations, + "linking an identity reaches no MCP server and spends no assertion, so gating it would be a second consultation buying nothing — and would make the rollout flag able to block a harmless, reusable action", + ).toEqual([]); + + yield* executor.oauth.start(connectWithHeldIdentity()); + expect( + consultations, + "the connect is still gated, still exactly once, and still before anything leaves the process", + ).toEqual([String(INTEG)]); + + yield* executor.execute(TOOL, {}); + expect( + consultations, + "and resolving credentials never re-consults it, so the flag can never strand a live connection", + ).toEqual([String(INTEG)]); + }), + ), + ); +}); + +describe("work identity — lifecycle", () => { + it.effect("renews after the ID token that started the link would have died", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + yield* linkWorkIdentity(executor.oauth, servers); + yield* executor.oauth.start(connectWithHeldIdentity()); + + // Kill everything the SSO issued that expires: the access token, and + // with it the ID token minted beside it. Under the old design — an ID + // token in custody — this is precisely the state a connection reaches + // an hour after it was made, and every renewal from here fails. + for (const token of yield* servers.idp.issuedAccessTokens) { + yield* servers.idp.revokeAccessToken(token); + } + + const first = (yield* executor.execute(TOOL, {})) as { readonly token: string }; + const second = (yield* executor.execute(TOOL, {})) as { readonly token: string }; + + expect(second.token, "the expiring access token was replaced").not.toBe(first.token); + expect( + yield* servers.resource.acceptsAccessToken(second.token), + "renewal still reaches the identity provider and still comes back with a usable token, because custody is the refresh token", + ).toBe(true); + const exchanges = yield* tokenExchanges(servers); + expect( + exchanges.length, + "every renewal returns to the IdP, so enterprise policy is re-evaluated each time", + ).toBeGreaterThan(1); + expect( + exchanges.every((params) => params.get("subject_token_type") === REFRESH_TOKEN_TYPE), + "and every one of them presents the durable subject", + ).toBe(true); + }), + ), + ); + + it.effect("turns a rejected identity into ONE re-link that revives every connection", () => + Effect.scoped( + Effect.gen(function* () { + const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); + const { executor } = yield* workspace(); + yield* executor.acme.seed(); + yield* registerClients(executor.oauth.createClient, servers); + const linked = yield* linkWorkIdentity(executor.oauth, servers); + assert(linked.status === "linked"); + yield* executor.oauth.start(connectWithHeldIdentity()); + yield* executor.oauth.start(connectWithHeldIdentity(SECOND_CONNECTION)); + yield* executor.execute(TOOL, {}); + yield* executor.execute(SECOND_TOOL, {}); + + // The enterprise ends the user's sessions. Both connections renew from + // the SAME held identity, so both meet it. + expect( + yield* servers.idp.revokeRefreshTokensFor(CLIENT_AT_IDP), + "one link means one durable subject at the identity provider, however many connections were made from it", + ).toBe(1); + + const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip); + assert( + Predicate.isTagged(failure, "CredentialResolutionError"), + "a dead identity is a credential verdict, not an execution fault", + ); + expect( + failure.workIdentityRelinkRequired, + "the remedy is a re-link, and a console that reads only `reauthRequired` would send the user to reconnect N connections instead", + ).toBe(true); + expect(failure.reauthRequired, "it is still definitive — no retry recovers it").toBe(true); + expect( + failure.blockedByAdmin, + "the subject died; the administrator withdrew nothing", + ).toBeUndefined(); + + const status = yield* executor.oauth.workIdentityStatus(WORK_IDENTITY); + assert( + status.status === "needs_relink", + "the rejection is recorded on the IDENTITY — one fact, not one per connection", + ); + expect(status.revokedReason).toBe("rejected"); + expect( + status.label, + "and the account it names survives, so the user knows what to re-link", + ).toBe(ACCOUNT_CLAIMS.email); + + const secondFailure = yield* executor.execute(SECOND_TOOL, {}).pipe(Effect.flip); + assert(Predicate.isTagged(secondFailure, "CredentialResolutionError")); + expect( + secondFailure.workIdentityRelinkRequired, + "the second connection reports the same one remedy", + ).toBe(true); + + const stalled = yield* executor.connections.list(); + expect( + healthOf(stalled, CONNECTION), + "both connections show the problem without waiting for a probe", + ).toMatchObject({ status: "expired" }); + expect(healthOf(stalled, SECOND_CONNECTION)).toMatchObject({ status: "expired" }); + + // ONE re-link. No reconnect, no `oauth.start`, nothing touching either + // connection — which is the claim: a dead work identity must not have + // stamped a reauth verdict onto connections that never lost anything. + const relinked = yield* linkWorkIdentity(executor.oauth, servers); + expect(relinked.status).toBe("linked"); + + const revivedFirst = (yield* executor.execute(TOOL, {})) as { readonly token: string }; + const revivedSecond = (yield* executor.execute(SECOND_TOOL, {})) as { + readonly token: string; + }; + expect( + yield* servers.resource.acceptsAccessToken(revivedFirst.token), + "the first connection renews again after the single re-link", + ).toBe(true); + expect( + yield* servers.resource.acceptsAccessToken(revivedSecond.token), + "and so does the second — which is the whole difference between a dead identity and N dead connections", + ).toBe(true); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-work-identity.ts b/packages/core/sdk/src/oauth-work-identity.ts new file mode 100644 index 0000000000..a171f9bd21 --- /dev/null +++ b/packages/core/sdk/src/oauth-work-identity.ts @@ -0,0 +1,353 @@ +// --------------------------------------------------------------------------- +// Work identity — the acquisition half of MCP Enterprise-Managed Authorization. +// +// The EMA connect path (`oauth.start` with `enterprise`) presents an identity +// assertion to the enterprise IdP and exchanges it for an ID-JAG. Nothing in the +// product ACQUIRED that assertion: the connect request simply required the +// caller to hand one over. A browser cannot obtain one — the IdP client's secret +// is server-side — so in practice there was no way to reach the profile from the +// console at all. +// +// A WORK IDENTITY closes that gap. A user links their enterprise identity ONCE +// per (owner, IdP client): executor runs an ordinary authorization-code flow +// against the org's registered IdP app, exchanges the code server-side with that +// app's credentials, and takes custody of the result. Every later +// enterprise-managed connect for that IdP client then resolves the held identity +// instead of asking the caller for one. +// +// WHAT IS HELD, AND WHY IT IS THE REFRESH TOKEN +// +// draft-ietf-oauth-identity-assertion-authz-grant §4.5 makes the refresh token a +// first-class `subject_token` for exactly this reason: an OIDC ID token lives +// about an hour, so an ID token in custody turns every renewal after that hour +// into "needs SSO" — the connection is dead a lunch break after it was made. A +// refresh token is the durable subject, and `SubjectTokenTypeSchema` already +// admits it. +// +// ID-token custody remains as an EXPLICIT, recorded degradation for an IdP that +// issues no refresh token (`tokenType` says which is held, and `expiresAt` +// carries the deadline). It is a different stored shape, not a silent fallback. +// +// CUSTODY LAYOUT +// +// The record lives in the default writable credential provider under a +// deterministic item id (see {@link workIdentityItemId}) — the same store the +// connection access/refresh material uses, with the same owner partitioning +// (`provider-item-owner.ts`). One record, N enterprise-managed connections: the +// connections POINT at this item rather than each keeping a private copy, which +// is what makes a single re-link recover all of them. +// --------------------------------------------------------------------------- + +import { Option, Schema } from "effect"; + +import { OAuthClientSlug, OAuthState, Owner, ProviderItemId } from "./ids"; +import { SubjectTokenTypeSchema, type SubjectTokenType } from "./oauth-client"; + +// --------------------------------------------------------------------------- +// Addressing +// --------------------------------------------------------------------------- + +/** Which held enterprise identity a request is about: the registered IdP app, + * and the owner partition the identity is filed under. + * + * `owner` is the owner the CONNECTIONS backed by this identity are made under, + * which is also the partition the credential provider files it in — a Personal + * connection resolves the user's own identity, a Workspace connection the + * org-shared one, exactly as an OAuth connection's tokens already work. */ +export const WorkIdentityRefSchema = Schema.Struct({ + owner: Owner, + idpClient: OAuthClientSlug, + idpClientOwner: Owner, +}).annotate({ + identifier: "WorkIdentityRef", + description: + "The (owner, enterprise identity provider app) pair a held work identity is keyed by.", +}); +export interface WorkIdentityRef extends Schema.Schema.Type {} + +/** The credential-provider item a work identity occupies. + * + * The `work-identity::…` grammar is deliberate: `provider-item-owner.ts` + * reads the second segment to decide which partition a provider files the value + * under, so this id must keep that shape or an org identity would be written + * into the acting member's private partition. */ +export const workIdentityItemId = (ref: WorkIdentityRef): ProviderItemId => + ProviderItemId.make(`work-identity:${ref.owner}:${ref.idpClientOwner}:${String(ref.idpClient)}`); + +// --------------------------------------------------------------------------- +// The persisted record +// --------------------------------------------------------------------------- + +/** Why a held identity stopped working. `rejected` is the only value the + * renewal path can produce: the IdP refused the stored subject at the token + * exchange (RFC 6749 `invalid_grant`), which is definitive — the user must link + * again. Kept as a closed set so the product can say something specific rather + * than rendering a stored sentence. */ +export const WorkIdentityRevocationReasonSchema = Schema.Literals(["rejected"]).annotate({ + identifier: "WorkIdentityRevocationReason", +}); +export type WorkIdentityRevocationReason = typeof WorkIdentityRevocationReasonSchema.Type; + +/** Everything custody of a work identity means, as stored in the credential + * provider. The whole record lives in the secret store — not just the token — + * because the account facts beside it (which enterprise account, when it was + * linked) describe a credential and belong under the same protection. + * + * Read it back with {@link decodeWorkIdentityRecord}; never with a cast. */ +export const WorkIdentityRecordSchema = Schema.Struct({ + /** The durable subject presented as `subject_token` on every later exchange: + * the IdP refresh token, or — when the IdP issued none — the ID token. */ + token: Schema.String, + /** RFC 8693 §3 type of `token`. Says which of the two custodies is in force. */ + tokenType: SubjectTokenTypeSchema, + /** The IdP's `sub` claim for this account. Stable across re-links; the + * identifier support and audit read. */ + subject: Schema.NullOr(Schema.String), + /** Display label from the ID token (`email`, else `preferred_username`, else + * `sub`) — what "linked as …" shows. */ + label: Schema.NullOr(Schema.String), + /** Epoch ms the link was made. */ + linkedAt: Schema.Number, + /** Epoch ms this custody stops working, when it is knowable — the ID token's + * `exp` under ID-token custody. Null for refresh-token custody: a refresh + * token has no client-visible expiry, which is the whole point of holding it. */ + expiresAt: Schema.NullOr(Schema.Number), + /** Scope the IdP granted on the link, as it echoed it. Recorded for support; + * never re-requested from here (each connect asks for the server's scopes). */ + scope: Schema.NullOr(Schema.String), + /** Set when the IdP rejected this subject. The record is KEPT so the product + * can say "re-link", and so every connection pointing at it short-circuits + * instead of re-spending a doomed exchange. */ + revokedAt: Schema.optional(Schema.Number), + revokedReason: Schema.optional(WorkIdentityRevocationReasonSchema), +}).annotate({ + identifier: "WorkIdentityRecord", + description: + "A user's held enterprise identity: the durable subject token plus the account facts describing it.", +}); +export interface WorkIdentityRecord extends Schema.Schema.Type {} + +const WorkIdentityRecordFromJson = Schema.fromJsonString(WorkIdentityRecordSchema); + +/** Parse a stored record. A record that does not decode is treated as ABSENT by + * callers — the product then says "not linked", which is both true (nothing + * usable is held) and recoverable (linking again overwrites it). */ +export const decodeWorkIdentityRecord = Schema.decodeUnknownOption(WorkIdentityRecordFromJson); + +/** Serialize a record for the credential provider. */ +export const encodeWorkIdentityRecord = Schema.encodeSync(WorkIdentityRecordFromJson); + +/** Whether this record can still be presented to the IdP. */ +export const isWorkIdentityUsable = (record: WorkIdentityRecord): boolean => + record.revokedAt === undefined; + +/** The record with the IdP's rejection recorded. Idempotent: a record already + * marked keeps its FIRST rejection timestamp, so "since when" stays true when + * several connections meet the same dead identity. */ +export const revokedWorkIdentity = ( + record: WorkIdentityRecord, + input: { readonly at: number; readonly reason: WorkIdentityRevocationReason }, +): WorkIdentityRecord => + record.revokedAt === undefined + ? { ...record, revokedAt: input.at, revokedReason: input.reason } + : record; + +// --------------------------------------------------------------------------- +// The read model +// --------------------------------------------------------------------------- + +const WorkIdentityAccount = { + /** The IdP's `sub` claim, when the ID token carried one. */ + subject: Schema.NullOr(Schema.String), + /** "Linked as …" — the account email, else `preferred_username`, else `sub`. */ + label: Schema.NullOr(Schema.String), + linkedAt: Schema.Number, + /** Which custody is in force. `…:refresh_token` survives ID-token expiry; + * `…:id_token` means the IdP issued no refresh token and this link dies at + * `expiresAt`. */ + subjectTokenType: SubjectTokenTypeSchema, + expiresAt: Schema.NullOr(Schema.Number), +} as const; + +/** What the console polls. Three states, discriminated by `status`, because the + * product does three different things: + * + * - `unlinked` → offer "Link your work identity". + * - `linked` → show the account; enterprise-managed connects will work. + * - `needs_relink` → an identity IS held but the IdP has rejected it. Every + * enterprise-managed connection backed by it is stalled and ONE re-link + * revives all of them — which is why this is not the same state as + * `unlinked`, and emphatically not the same as a dead connection. */ +export const WorkIdentityStatusSchema = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("unlinked"), + idpClient: OAuthClientSlug, + idpClientOwner: Owner, + owner: Owner, + }), + Schema.Struct({ + status: Schema.Literal("linked"), + idpClient: OAuthClientSlug, + idpClientOwner: Owner, + owner: Owner, + ...WorkIdentityAccount, + }), + Schema.Struct({ + status: Schema.Literal("needs_relink"), + idpClient: OAuthClientSlug, + idpClientOwner: Owner, + owner: Owner, + ...WorkIdentityAccount, + revokedAt: Schema.Number, + revokedReason: WorkIdentityRevocationReasonSchema, + }), +]).annotate({ + identifier: "WorkIdentityStatus", + description: + "Whether a user holds a usable enterprise identity for an IdP app, and which account it is.", +}); +export type WorkIdentityStatus = typeof WorkIdentityStatusSchema.Type; + +/** Project a stored record onto the read model. Carries the account facts and + * NOTHING that could stand in for the credential: `token` has no path here. */ +export const workIdentityStatusOf = ( + ref: WorkIdentityRef, + record: WorkIdentityRecord | null, +): WorkIdentityStatus => { + if (record === null) { + return { + status: "unlinked", + owner: ref.owner, + idpClient: ref.idpClient, + idpClientOwner: ref.idpClientOwner, + }; + } + const account = { + owner: ref.owner, + idpClient: ref.idpClient, + idpClientOwner: ref.idpClientOwner, + subject: record.subject, + label: record.label, + linkedAt: record.linkedAt, + subjectTokenType: record.tokenType, + expiresAt: record.expiresAt, + } as const; + return record.revokedAt === undefined + ? { status: "linked", ...account } + : { + status: "needs_relink", + ...account, + revokedAt: record.revokedAt, + revokedReason: record.revokedReason ?? "rejected", + }; +}; + +// --------------------------------------------------------------------------- +// Flow inputs +// --------------------------------------------------------------------------- + +/** The default scope set a link requests. `openid` is what makes the token + * endpoint return an ID token, which is the only place the account facts come + * from; `offline_access` is how an OIDC provider is asked for the refresh token + * this whole design holds. Deliberately NOT narrowed against the IdP's + * advertised `scopes_supported`: both are standard OIDC scopes that many + * authorization servers decline to enumerate, and dropping either would quietly + * cost the link its account facts or its durability. An IdP that spells them + * differently is served by the explicit `scopes` override. */ +export const DEFAULT_WORK_IDENTITY_SCOPES: readonly string[] = ["openid", "offline_access"]; + +export interface StartWorkIdentityLinkInput extends WorkIdentityRef { + /** Replace {@link DEFAULT_WORK_IDENTITY_SCOPES} outright. An override, not an + * addition: an IdP that rejects `offline_access` needs the default GONE, not + * supplemented. Omit unless the IdP requires it. */ + readonly scopes?: readonly string[]; + /** Browser-facing callback for this link. Defaults to the executor's + * configured work-identity callback. */ + readonly redirectUri?: string | null; +} + +export interface CompleteWorkIdentityLinkInput { + readonly state: OAuthState; + readonly code: string; +} + +// --------------------------------------------------------------------------- +// The in-flight session +// +// A link reuses `oauth_session` — the same table, TTL and cleanup the connect +// flow uses — rather than growing a parallel one. What distinguishes the two is +// the payload, and it is CHECKED rather than assumed: a work-identity state +// handed to `complete` must not be able to mint a connection out of the sentinel +// columns, and a connect state handed to `completeWorkIdentityLink` must not be +// able to redeem a code into someone's identity custody. +// --------------------------------------------------------------------------- + +/** Value written to `oauth_session`'s integration/name/template columns for a + * link. They are non-null and mean nothing here — a link targets no + * integration — so they carry a self-describing sentinel instead of an empty + * string that would read as data. The payload is what completion parses. */ +export const WORK_IDENTITY_SESSION_SENTINEL = "work-identity"; + +export const WorkIdentitySessionPayloadSchema = Schema.Struct({ + kind: Schema.Literal("work-identity"), + owner: Owner, + idpClient: OAuthClientSlug, + idpClientOwner: Owner, + /** What the authorize request asked for; the recorded-scope fallback when the + * IdP's token response omits `scope`. */ + requestedScopes: Schema.Array(Schema.String), +}).annotate({ identifier: "WorkIdentitySessionPayload" }); +export interface WorkIdentitySessionPayload extends Schema.Schema.Type< + typeof WorkIdentitySessionPayloadSchema +> {} + +const decodeWorkIdentitySessionPayload = Schema.decodeUnknownOption( + WorkIdentitySessionPayloadSchema, +); + +/** The link this session belongs to, or null when the session is not one. The + * ONLY way either completion path decides which flow it is looking at. */ +export const workIdentitySessionPayloadFrom = ( + payload: unknown, +): WorkIdentitySessionPayload | null => Option.getOrNull(decodeWorkIdentitySessionPayload(payload)); + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** A work-identity link could not be started or completed. Deliberately its own + * tag rather than an `OAuthCompleteError`: nothing about it concerns a + * connection, and a console that renders "could not connect" for a failed link + * sends the user to the wrong place. */ +export class WorkIdentityLinkError extends Schema.TaggedErrorClass()( + "WorkIdentityLinkError", + { + message: Schema.String, + /** True when the flow cannot be resumed and the user must start the link + * again (an expired or already-redeemed authorization). */ + restartRequired: Schema.optional(Schema.Boolean), + }, +) { + readonly __executorUserActionable = true; + readonly code = "work_identity_link_error"; + + get userMessage(): string { + return this.message; + } +} + +/** The subject-token type a link takes custody of, given what the IdP returned. + * Refresh token when there is one (§4.5); ID token otherwise, which is custody + * of something that expires and is recorded as such. */ +export const workIdentityCustodyType = (input: { + readonly refreshToken: string | undefined; + readonly idToken: string | undefined; +}): SubjectTokenType | null => { + if (input.refreshToken !== undefined && input.refreshToken.length > 0) { + return "urn:ietf:params:oauth:token-type:refresh_token"; + } + if (input.idToken !== undefined && input.idToken.length > 0) { + return "urn:ietf:params:oauth:token-type:id_token"; + } + return null; +}; diff --git a/packages/core/sdk/src/provider-item-owner.ts b/packages/core/sdk/src/provider-item-owner.ts index 74e857fdb1..ae1733cd22 100644 --- a/packages/core/sdk/src/provider-item-owner.ts +++ b/packages/core/sdk/src/provider-item-owner.ts @@ -6,6 +6,7 @@ // connection:::: // oauth:::[:refresh] // oauth-client:::secret +// work-identity::: // // Credential providers file plugin-storage rows by THIS owner, not the acting // caller's binding — an org connection whose OAuth consent completes in one @@ -21,6 +22,11 @@ export const OWNER_SCOPED_ITEM_ID_PREFIXES: ReadonlySet = new Set([ "connection", "oauth", "oauth-client", + // A held enterprise identity is filed under the owner its enterprise-managed + // connections are made under, for the same reason (#950, #1453): a Workspace + // connection linked in one member's browser must stay resolvable by the rest + // of the org, and a Personal one must stay private to its subject. + "work-identity", ]); /** The owner a logical item id embeds, or null for ids that carry none diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index c0dcfc5de0..5881148cce 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -173,8 +173,25 @@ export { OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, + type OAuthCallbackCompletion, + type WorkIdentityLinkStart, } from "./oauth-client"; +// Work-identity wire contracts (the link flow's payloads, status projection and +// tagged error). The persisted record and its custody rules stay server-only. +export { + DEFAULT_WORK_IDENTITY_SCOPES, + WorkIdentityLinkError, + WorkIdentityRefSchema, + WorkIdentityRevocationReasonSchema, + WorkIdentityStatusSchema, + type CompleteWorkIdentityLinkInput, + type StartWorkIdentityLinkInput, + type WorkIdentityRef, + type WorkIdentityRevocationReason, + type WorkIdentityStatus, +} from "./oauth-work-identity"; + // Wire-level HTTP error schema for plugin HttpApiGroup definitions. export { InternalError } from "./api-errors"; diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index c6a0d73ee4..b2cb2122a2 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -64,6 +64,12 @@ export interface OAuthTestServerOptions { readonly scopes?: readonly string[]; readonly omitTokenResponseScopes?: readonly string[]; readonly supportRefresh?: boolean; + /** Whether the authorization-code grant ISSUES a refresh token at all. + * Default true. Set false to stand in for an identity provider that hands + * back only an access + ID token, which is the case that forces work-identity + * custody onto the (expiring) ID token. Distinct from `supportRefresh`, which + * governs whether an ISSUED refresh token can still be redeemed. */ + readonly issueRefreshToken?: boolean; readonly tokenExpiresInSeconds?: number; readonly invalidRefreshTokenDescription?: string; /** RFC 6749 error code returned when a refresh-token grant is rejected. @@ -152,6 +158,15 @@ export interface OAuthTestServerShape { * fixture rejects the exchange afterwards exactly as it would for an expired * or revoked assertion. */ readonly revokeAccessToken: (token: string) => Effect.Effect; + /** Stop honouring every refresh token this server issued to a client, and + * report how many that was. Models the enterprise-side action a client cannot + * see: an administrator ends the user's sessions, and the next ID-JAG exchange + * presenting one of those refresh tokens is rejected as `invalid_grant`. + * + * Keyed by CLIENT rather than by token value on purpose — a test driving this + * holds no refresh token (custody is the server's), so revoking by value would + * force it to reach into private state to find one. */ + readonly revokeRefreshTokensFor: (clientId: string) => Effect.Effect; /** Start (or stop) refusing every RFC 8693 exchange with this §5.2 error. * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives * the case that only exists over time — an administrator withdrawing access @@ -528,6 +543,7 @@ export const serveOAuthTestServer = ( ...(options.users ?? {}), }; const supportRefresh = options.supportRefresh ?? true; + const issueRefreshToken = options.issueRefreshToken ?? true; const tokenExpiresInSeconds = options.tokenExpiresInSeconds ?? 3600; const invalidRefreshTokenDescription = options.invalidRefreshTokenDescription ?? "Unknown refresh token"; @@ -791,20 +807,22 @@ export const serveOAuthTestServer = ( } authorizationCodes.delete(code); const accessToken = `at_${randomUUID()}`; - const refreshToken = `rt_${randomUUID()}`; + const refreshToken = issueRefreshToken ? `rt_${randomUUID()}` : null; yield* Ref.update(issuedAccessTokens, (tokens) => new Set([...tokens, accessToken])); - refreshTokens.set(refreshToken, { - clientId, - username: record.username, - scope: record.scope, - resource: record.resource, - }); + if (refreshToken !== null) { + refreshTokens.set(refreshToken, { + clientId, + username: record.username, + scope: record.scope, + resource: record.resource, + }); + } const scope = tokenResponseScope(record.scope); return jsonResponse( 200, { access_token: accessToken, - refresh_token: refreshToken, + ...(refreshToken === null ? {} : { refresh_token: refreshToken }), token_type: "Bearer", expires_in: tokenExpiresInSeconds, ...(scope ? { scope } : {}), @@ -907,9 +925,18 @@ export const serveOAuthTestServer = ( if (denial) { return oauthError(400, denial.error, denial.errorDescription); } - const subjectAccepted = yield* Ref.get(issuedAccessTokens).pipe( - Effect.map((tokens) => tokens.has(subjectToken)), - ); + // draft §4.5: a REFRESH token is a first-class subject, and it is + // VALIDATED, NOT CONSUMED — the exchange is not a refresh-token + // grant and must not rotate the client's durable subject out from + // under it. (Matches the Okta emulator's behavior, which this + // fixture stands in for in the hermetic tests.) A refresh token is + // also only acceptable from the client it was issued to. + const subjectAccepted = + subjectTokenType === "urn:ietf:params:oauth:token-type:refresh_token" + ? refreshTokens.get(subjectToken)?.clientId === clientId + : yield* Ref.get(issuedAccessTokens).pipe( + Effect.map((tokens) => tokens.has(subjectToken)), + ); if (!subjectAccepted) { return oauthError( 400, @@ -1069,6 +1096,14 @@ export const serveOAuthTestServer = ( next.delete(token); return next; }), + revokeRefreshTokensFor: (clientId) => + Effect.sync(() => { + const doomed = [...refreshTokens.entries()].filter( + ([, record]) => record.clientId === clientId, + ); + for (const [token] of doomed) refreshTokens.delete(token); + return doomed.length; + }), setTokenExchangeDenial: (denial) => Ref.set(tokenExchangeDenial, denial), acceptsAuthorizationHeader: (authorization) => { const token = authorization?.replace(/^Bearer\s+/i, "");