From 33cad419d6438de9892ee166b63e0cf78b67ce7b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:40:08 -0700 Subject: [PATCH 1/4] Acquire and hold the work identity enterprise-managed connects present oauth.start required the caller to supply the identity assertion, and nothing in the product could obtain one: the IdP client's secret is server-side, so a browser could not reach the profile at all. Whatever was passed also became the connection's renewal credential, so an ID token made every renewal past its first hour fail needs-SSO. Link the identity once per (owner, IdP app) instead. The link runs the IdP app's own authorization-code flow through the existing OAuth machinery -- one oauth_session row, buildAuthorizationUrl, exchangeAuthorizationCode, the shared callback -- redeems the code server-side, and takes custody of the REFRESH token (id-jag draft 4.5), falling back to recorded ID-token custody with its deadline when the IdP issues none. oauth.start's subjectToken is now optional: omitted, it resolves the held identity and points the connection's refresh slot at the shared record rather than copying it. An explicit assertion keeps its private copy and behaves exactly as before. --- packages/core/sdk/src/index.ts | 22 + packages/core/sdk/src/oauth-client.ts | 109 ++- packages/core/sdk/src/oauth-ema.ts | 16 + packages/core/sdk/src/oauth-helpers.ts | 48 +- packages/core/sdk/src/oauth-service.ts | 533 +++++++++++++-- .../core/sdk/src/oauth-work-identity.test.ts | 641 ++++++++++++++++++ packages/core/sdk/src/oauth-work-identity.ts | 353 ++++++++++ packages/core/sdk/src/provider-item-owner.ts | 6 + packages/core/sdk/src/shared.ts | 17 + .../core/sdk/src/testing/oauth-test-server.ts | 57 +- 10 files changed, 1730 insertions(+), 72 deletions(-) create mode 100644 packages/core/sdk/src/oauth-work-identity.test.ts create mode 100644 packages/core/sdk/src/oauth-work-identity.ts 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, ""); From 3faa90c6b788a1537f44083f96f300a780bc1f39 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:40:14 -0700 Subject: [PATCH 2/4] Make a dead work identity one re-link, not N dead connections The renewal path treated every rejected subject the same: mark the connection dead and demand a reconnect. For a shared work identity that is wrong twice over -- the connection lost nothing, and reconnecting N of them fixes nothing one re-link would not. Record the rejection on the identity record instead, so every connection behind it short-circuits before spending its own doomed exchange, and surface it as workIdentityRelinkRequired. Connections get health only, never the reauth stamp that a reconnect alone clears, so the next resolve after a re-link succeeds untouched. --- packages/core/sdk/src/errors.ts | 7 ++ packages/core/sdk/src/executor.ts | 156 ++++++++++++++++++++++++++---- 2 files changed, 145 insertions(+), 18 deletions(-) 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 From c4be99b59ab666732b62c125b3fe3fe8fd7455f8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:40:22 -0700 Subject: [PATCH 3/4] Serve the work identity link, status and unlink over HTTP Four routes next to the existing oauth ones: start, complete, a status a console polls, and unlink. The status is the browser-facing projection of a credential record, so it carries the account and the custody kind and nothing that could stand in for the token. Both browser flows land on the one callback URL an enterprise registers, so the callback route now asks the in-flight session which flow came back rather than inferring from the URL. A connection's popup payload is unchanged; a link's is spread under its own key, since the two objects share field names. --- packages/core/api/src/handlers/oauth.ts | 83 +++++- packages/core/api/src/oauth/api.ts | 89 ++++++ .../src/oauth/work-identity-handlers.test.ts | 256 ++++++++++++++++++ 3 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 packages/core/api/src/oauth/work-identity-handlers.test.ts 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" }); + }), + ), + ); +}); From 2b6b7ed401160c3dfd5f80656c92b5d460ff3b05 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:40:22 -0700 Subject: [PATCH 4/4] Cover the work identity path end to end against the Okta emulator A selfhost scenario runs the whole thing through the product: link via a real OIDC sign-in, connect with no subjectToken in the payload, call a tool. Both emulator ledgers carry the proof -- the sign-in, executor's own code redemption, the exchange and the redemption -- and the MCP server sees no authorize request. Renewal past ID-token expiry is not asserted here: the Okta emulator's token lifetimes are compiled-in constants with no seed knob to compress them. That claim is proven hermetically in the SDK suite instead. --- e2e/selfhost/mcp-ema-work-identity.test.ts | 442 +++++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 e2e/selfhost/mcp-ema-work-identity.test.ts 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); + }), + ); + }), + ), +);