History

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.

Rhys Sullivan committed 2 months ago

Commit 33cad411 parent 2c75d7a10 files · +1730 −72Browse files

Unified · Split

10 files
  1. packages/core/sdk/src/index.ts +22 −0
  2. packages/core/sdk/src/oauth-client.ts +100 −9
  3. packages/core/sdk/src/oauth-ema.ts +16 −0
  4. packages/core/sdk/src/oauth-helpers.ts +45 −3
  5. packages/core/sdk/src/oauth-service.ts +484 −49
  6. packages/core/sdk/src/oauth-work-identity.test.ts +641 −0
  7. packages/core/sdk/src/oauth-work-identity.ts +353 −0
  8. packages/core/sdk/src/provider-item-owner.ts +6 −0
  9. packages/core/sdk/src/shared.ts +17 −0
  10. packages/core/sdk/src/testing/oauth-test-server.ts +46 −11
packages/core/sdk/src/index.ts+22−0
@@ -315,8 +315,30 @@ export { · 314 unmodified lines
315315 type OAuthProbeInput,
316316 type OAuthProbeResult,
317317 type OAuthService,
318+ type OAuthCallbackCompletion,
319+ type WorkIdentityLinkStart,
318320 } from "./oauth-client";
319321
322+// Work identity — how the enterprise assertion the EMA connect consumes is
323+// acquired and held. Contracts + the persisted record's schema; the flow itself
324+// lives in the OAuth service.
325+export {
326+ DEFAULT_WORK_IDENTITY_SCOPES,
327+ WORK_IDENTITY_SESSION_SENTINEL,
328+ WorkIdentityLinkError,
329+ WorkIdentityRefSchema,
330+ WorkIdentityRevocationReasonSchema,
331+ WorkIdentityStatusSchema,
332+ workIdentityItemId,
333+ workIdentityStatusOf,
334+ type CompleteWorkIdentityLinkInput,
335+ type StartWorkIdentityLinkInput,
336+ type WorkIdentityRecord,
337+ type WorkIdentityRef,
338+ type WorkIdentityRevocationReason,
339+ type WorkIdentityStatus,
340+} from "./oauth-work-identity";
341+
320342 // The enterprise-managed rollout PORT (not its implementation): hosts that
321343 // operate a feature-flag service implement this and hand it to
322344 // `createExecutor`. Core depends on no vendor.
packages/core/sdk/src/oauth-client.ts+100−9
@@ -12,6 +12,13 @@ import { · 11 unmodified lines
1212 OAuthState,
1313 Owner,
1414 } from "./ids";
15+import type {
16+ CompleteWorkIdentityLinkInput,
17+ StartWorkIdentityLinkInput,
18+ WorkIdentityLinkError,
19+ WorkIdentityRef,
20+ WorkIdentityStatus,
21+} from "./oauth-work-identity";
1522
1623 /** RFC 8693 §3 security token type identifiers usable as a `subject_token_type`
1724 * when exchanging an enterprise identity assertion for an ID-JAG. The id-jag
@@ -277,9 +284,19 @@ export interface OAuthStartInput { · 259 unmodified lines
277284 }
278285
279286 /** What an enterprise-managed connect needs beyond the ordinary start inputs.
280− * The subject token is supplied by the caller because "where the identity
281− * assertion comes from" is a host concern: a desktop app holds its own SSO
282− * tokens, a hosted deployment holds the session's.
287+ *
288+ * Two ways to say who is connecting, and the difference is custody:
289+ *
290+ * - OMIT `subjectToken` (the console's path). The server resolves the user's
291+ * HELD work identity for this IdP app — the durable refresh-token subject a
292+ * `oauth.startWorkIdentityLink` put in custody. The minted connection points
293+ * at that shared record, so renewals outlive any ID token and one re-link
294+ * revives every connection made this way. Not linked yet → the start fails
295+ * with `workIdentityLinkRequired`, which is the console's cue to link.
296+ * - SUPPLY `subjectToken` (the API/headless path). The caller is the source of
297+ * the assertion, exactly as before: a desktop app holding its own SSO
298+ * tokens, or a script. The connection keeps a private copy of what was
299+ * passed, and its lifetime is whatever that token's lifetime is.
283300 *
284301 * Declared as a Schema because this shape crosses the HTTP boundary: the API's
285302 * `oauth.start` payload embeds THIS schema rather than restating its fields. */
@@ -287,19 +304,31 @@ export const EnterpriseManagedStartInputSchema = Schema.Struct({ · 1 unmodified line
287304 /** `oauth_client` slug of the client's registration at the enterprise IdP. */
288305 idpClient: OAuthClientSlug,
289306 idpClientOwner: Owner,
290− /** The identity assertion from single sign-on with the IdP (an OIDC ID token
291− * by default). Persisted through the credential provider so token renewal
292− * needs no further user interaction. */
293− subjectToken: Schema.String,
294− /** RFC 8693 §3 type of `subjectToken`. Defaults to an OIDC ID token. */
307+ /** The identity assertion from single sign-on with the IdP. OMIT to use the
308+ * work identity the user already linked for `idpClient`; supply one to make
309+ * the caller the source, in which case the connection takes custody of
310+ * exactly this value. */
311+ subjectToken: Schema.optional(Schema.String),
312+ /** RFC 8693 §3 type of `subjectToken`. Read only alongside an explicit
313+ * `subjectToken`; a resolved work identity carries its own type (its custody
314+ * decides it, not the caller). Defaults to an OIDC ID token. */
295315 subjectTokenType: Schema.optional(SubjectTokenTypeSchema),
296316 }).annotate({
297317 identifier: "EnterpriseManagedStartInput",
298318 description:
299− "The second client registration (at the enterprise identity provider) and the identity assertion an enterprise-managed connect presents.",
319+ "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.",
300320 });
301321 export type EnterpriseManagedStartInput = typeof EnterpriseManagedStartInputSchema.Type;
302322
323+/** What one OAuth callback turned out to be. The redirect URI is shared by both
324+ * browser flows executor runs — connecting an integration, and linking a work
325+ * identity — so the callback edge cannot know which it received until the
326+ * session says. The IN-FLIGHT SESSION is that answer; nothing is inferred from
327+ * the URL. */
328+export type OAuthCallbackCompletion =
329+ | { readonly kind: "connection"; readonly connection: Connection }
330+ | { readonly kind: "work-identity"; readonly workIdentity: WorkIdentityStatus };
331+
303332 export interface OAuthCompleteInput {
304333 readonly state: OAuthState;
305334 readonly code: string;
@@ -377,6 +406,13 @@ export class OAuthStartError · 71 unmodified lines
377406 * token-endpoint refusal. A typed field rather than message text so
378407 * telemetry and support tooling read the verdict structurally. */
379408 oauthErrorCode: Schema.optional(Schema.String),
409+ /** True when this enterprise-managed connect could not proceed because the
410+ * user holds no usable work identity for the named IdP app — never linked,
411+ * or linked and since rejected. The remedy is a work-identity LINK, not a
412+ * retry and not the interactive per-server flow, so a console branches on
413+ * this field to send the user to the right place. Never set together with
414+ * `blockedByAdmin`: nothing was asked of the IdP. */
415+ workIdentityLinkRequired: Schema.optional(Schema.Boolean),
380416 })
381417 implements UserActionableError
382418 {
@@ -467,8 +503,63 @@ export interface OAuthService { · 84 unmodified lines
467503 readonly complete: (
468504 input: OAuthCompleteInput,
469505 ) => Effect.Effect<Connection, OAuthCompleteError | OAuthSessionNotFoundError | StorageFailure>;
506+ /** Complete whatever flow this callback belongs to, as decided by the stored
507+ * session. The HTTP callback route calls THIS; `complete` and
508+ * `completeWorkIdentityLink` remain the direct, single-purpose entry points
509+ * for callers that already know which flow they started, and each refuses a
510+ * state belonging to the other. */
511+ readonly completeCallback: (
512+ input: OAuthCompleteInput,
513+ ) => Effect.Effect<
514+ OAuthCallbackCompletion,
515+ OAuthCompleteError | WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure
516+ >;
470517 readonly cancel: (state: OAuthState) => Effect.Effect<void, StorageFailure>;
471518 readonly probe: (
472519 input: OAuthProbeInput,
473520 ) => Effect.Effect<OAuthProbeResult, OAuthProbeError | StorageFailure>;
521+
522+ // -------------------------------------------------------------------------
523+ // Work identity — acquiring the enterprise assertion `start` consumes.
524+ //
525+ // A separate flow from `start`/`complete` on purpose. It mints NO connection
526+ // and touches no integration: it links a PERSON to an enterprise IdP app, once,
527+ // and every enterprise-managed connect for that app afterwards resolves it.
528+ // Its callback is its own route for the same reason — a link redirect must not
529+ // be able to arrive at a handler that would try to mint a connection from it.
530+ // -------------------------------------------------------------------------
531+
532+ /** Begin linking the caller's enterprise identity: build the authorization URL
533+ * for the registered IdP app and persist the in-flight session. Returns the
534+ * URL to visit and the state that identifies the flow. */
535+ readonly startWorkIdentityLink: (
536+ input: StartWorkIdentityLinkInput,
537+ ) => Effect.Effect<WorkIdentityLinkStart, WorkIdentityLinkError | StorageFailure>;
538+ /** Redeem a link's authorization code with the IdP app's own credentials and
539+ * take custody of the durable subject. Returns the resulting status, so the
540+ * caller that completes the flow already knows which account was linked. */
541+ readonly completeWorkIdentityLink: (
542+ input: CompleteWorkIdentityLinkInput,
543+ ) => Effect.Effect<
544+ WorkIdentityStatus,
545+ WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure
546+ >;
547+ /** Whether a usable enterprise identity is held for this (owner, IdP app), and
548+ * which account it is. The console's poll; never returns credential material. */
549+ readonly workIdentityStatus: (
550+ ref: WorkIdentityRef,
551+ ) => Effect.Effect<WorkIdentityStatus, StorageFailure>;
552+ /** Drop a held identity. Idempotent. Enterprise-managed connections backed by
553+ * it stop renewing and report that a link is required — they are NOT removed,
554+ * because linking again revives them. */
555+ readonly unlinkWorkIdentity: (ref: WorkIdentityRef) => Effect.Effect<void, StorageFailure>;
556+}
557+
558+/** Where to send the user to link their enterprise identity, and the state that
559+ * identifies the flow on the way back. Mirrors `ConnectResult`'s redirect arm —
560+ * there is no "connected" counterpart, because a link ALWAYS requires the
561+ * user's browser to visit the IdP. */
562+export interface WorkIdentityLinkStart {
563+ readonly authorizationUrl: string;
564+ readonly state: OAuthState;
474565 }
packages/core/sdk/src/oauth-ema.ts+16−0
@@ -64,6 +64,22 @@ export const EnterpriseManagedConnectionStateSchema = Schema.Struct({ · 63 unmodified lines
6464 * its RFC 8414 metadata when the connection was made. */
6565 audience: Schema.String,
6666 subjectTokenType: SubjectTokenTypeSchema,
67+ /** Where the subject token in `refresh_item_id` came from, which decides who
68+ * OWNS it and therefore what "it died" means:
69+ *
70+ * - `caller` (the default for a connection minted before work identities
71+ * existed, and for every headless caller that passes its own assertion):
72+ * the connection holds a private copy. A rejection kills THIS connection
73+ * and only a reconnect carrying a fresh assertion revives it.
74+ * - `work-identity`: `refresh_item_id` points at the user's shared work
75+ * identity record (`work-identity:…`), not a private copy. A rejection
76+ * kills the IDENTITY — every connection pointing at it is stalled, and one
77+ * re-link revives them all, so nothing may be recorded on the connection
78+ * that a reconnect would have to clear.
79+ *
80+ * Optional because rows written before this existed carry none, and their
81+ * custody is by construction the `caller` shape. */
82+ subjectSource: Schema.optional(Schema.Literals(["caller", "work-identity"])),
6783 }).annotate({
6884 identifier: "EnterpriseManagedConnectionState",
6985 description:
packages/core/sdk/src/oauth-helpers.ts+45−3
@@ -48,6 +48,19 @@ export type OAuth2TokenResponse = { · 47 unmodified lines
4848 readonly expires_in?: number;
4949 readonly scope?: string;
5050 readonly idTokenIdentityLabel?: string;
51+ /**
52+ * The raw OIDC ID token the endpoint returned, when it returned one.
53+ *
54+ * Named in camelCase deliberately: this is NOT the wire field (that one is
55+ * stripped before oauth4webapi validates the response — see `stripIdToken`),
56+ * so nothing can re-serialize this object as an RFC 6749 token response and
57+ * accidentally re-emit it.
58+ *
59+ * It exists for ONE caller: the enterprise work-identity link, which takes
60+ * custody of the ID token when the IdP issues no refresh token. The ordinary
61+ * connection mint reads named fields and never persists this.
62+ */
63+ readonly idToken?: string;
5164 };
5265
5366 // ---------------------------------------------------------------------------
@@ -720,9 +733,33 @@ export const idTokenIdentityLabel = (idToken: string | undefined): string | unde · 666 unmodified lines
720733 );
721734 };
722735
736+/** The account an ID token names, as far as a display and a deadline go.
737+ *
738+ * Read UNVERIFIED, and only ever used that way: these claims describe a token
739+ * we already hold from a token endpoint we just authenticated to, so they are
740+ * provenance for the user's benefit, never an authorization input. `expiresAt`
741+ * is epoch MILLIseconds (`exp` is seconds on the wire). */
742+export const idTokenAccountFacts = (
743+ idToken: string | undefined,
744+): {
745+ readonly subject: string | null;
746+ readonly label: string | null;
747+ readonly expiresAt: number | null;
748+} => {
749+ const claims = idToken === undefined ? null : decodeJwtPayload(idToken);
750+ if (claims === null) return { subject: null, label: null, expiresAt: null };
751+ const exp = claims.exp;
752+ return {
753+ subject: stringClaim(claims, "sub") ?? null,
754+ label: idTokenIdentityLabel(idToken) ?? null,
755+ expiresAt: typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null,
756+ };
757+};
758+
723759 type StrippedTokenResponse = {
724760 readonly response: Response;
725761 readonly idTokenIdentityLabel?: string;
762+ readonly idToken?: string;
726763 };
727764
728765 const NestedAuthedUserScope = Schema.Struct({
@@ -791,6 +828,7 @@ const stripIdToken = async (response: Response): Promise<StrippedTokenResponse> · 62 unmodified lines
791828 headers: response.headers,
792829 }),
793830 ...(label ? { idTokenIdentityLabel: label } : {}),
831+ ...(typeof idToken === "string" && idToken.length > 0 ? { idToken } : {}),
794832 };
795833 };
796834
@@ -817,9 +855,13 @@ const processTokenEndpointResponse = async ( · 20 unmodified lines
817855 scope: providerUserGrant.scope,
818856 }
819857 : parsed;
820− return stripped.idTokenIdentityLabel
821− ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel }
822− : token;
858+ return {
859+ ...token,
860+ ...(stripped.idTokenIdentityLabel === undefined
861+ ? {}
862+ : { idTokenIdentityLabel: stripped.idTokenIdentityLabel }),
863+ ...(stripped.idToken === undefined ? {} : { idToken: stripped.idToken }),
864+ };
823865 };
824866
825867 // ---------------------------------------------------------------------------
packages/core/sdk/src/oauth-service.ts+484−49
@@ -44,6 +44,8 @@ import { · 43 unmodified lines
4444 type CreateOAuthClientInput,
4545 type EnterpriseManagedStartInput,
4646 type FirstPartyOAuthClientConfig,
47+ type OAuthCallbackCompletion,
48+ type WorkIdentityLinkStart,
4749 type OAuthClientOrigin,
4850 type OAuthClientSummary,
4951 type OAuthCompleteInput,
@@ -75,6 +77,23 @@ import { · 25 unmodified lines
7577 type EnterpriseManagedRolloutDecision,
7678 type EnterpriseManagedRolloutEvent,
7779 } from "./oauth-ema";
80+import {
81+ DEFAULT_WORK_IDENTITY_SCOPES,
82+ WORK_IDENTITY_SESSION_SENTINEL,
83+ WorkIdentityLinkError,
84+ decodeWorkIdentityRecord,
85+ encodeWorkIdentityRecord,
86+ isWorkIdentityUsable,
87+ workIdentityCustodyType,
88+ workIdentityItemId,
89+ workIdentitySessionPayloadFrom,
90+ workIdentityStatusOf,
91+ type CompleteWorkIdentityLinkInput,
92+ type StartWorkIdentityLinkInput,
93+ type WorkIdentityRecord,
94+ type WorkIdentityRef,
95+ type WorkIdentityStatus,
96+} from "./oauth-work-identity";
7897 import {
7998 assertSupportedOAuthEndpointUrl,
8099 buildAuthorizationUrl,
@@ -84,6 +103,7 @@ import { · 3 unmodified lines
84103 createPkceCodeVerifier,
85104 exchangeAuthorizationCode,
86105 exchangeClientCredentials,
106+ idTokenAccountFacts,
87107 isLoopbackHttpUrl,
88108 rebindTokenEndpointHostToCallbackDomain,
89109 type OAuth2TokenResponse,
@@ -343,16 +363,25 @@ export const missingGrantedOAuthScopes = ( · 253 unmodified lines
343363
344364 const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
345365
346−/** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The
347− * jsonColumn may surface as a parsed object (in-memory backends) or a JSON
348− * string (serialized backends); decode strings before reading. Returns `null`
349− * for legacy sessions written before `requestedScopes` was persisted, so
350− * `complete` can fall back to the client's scopes. */
366+/** The stored `oauth_session` row, as this module reads it. `looseDb` returns an
367+ * untyped record; naming the columns we touch keeps the reads honest without
368+ * claiming a decoded row. */
369+type SessionRow = Readonly<Record<string, unknown>>;
370+
371+/** An `oauth_session.payload` as a value, whichever way the backend surfaced it.
372+ * The jsonColumn is a parsed object on in-memory backends and a JSON string on
373+ * serialized ones; every payload reader starts here so that difference is
374+ * handled exactly once. */
375+const sessionPayload = (payload: unknown): unknown =>
376+ typeof payload === "string"
377+ ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload))
378+ : payload;
379+
380+/** Extract the persisted `requestedScopes` from an `oauth_session.payload`.
381+ * Returns `null` for legacy sessions written before `requestedScopes` was
382+ * persisted, so `complete` can fall back to the client's scopes. */
351383 const requestedScopesFromPayload = (payload: unknown): readonly string[] | null => {
352− const decoded =
353− typeof payload === "string"
354− ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload))
355− : payload;
384+ const decoded = sessionPayload(payload);
356385 if (decoded === null || typeof decoded !== "object") return null;
357386 const value = (decoded as Record<string, unknown>).requestedScopes;
358387 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 · 3 unmodified lines
362391 * (same-owner connects, or sessions written before this field), so `complete`
363392 * falls back to the session owner. */
364393 const clientOwnerFromPayload = (payload: unknown): Owner | null => {
365− const decoded =
366− typeof payload === "string"
367− ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload))
368− : payload;
394+ const decoded = sessionPayload(payload);
369395 if (decoded === null || typeof decoded !== "object") return null;
370396 const value = (decoded as Record<string, unknown>).clientOwner;
371397 return value === "user" || value === "org" ? value : null;
@@ -1292,6 +1318,353 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 920 unmodified lines
12921318 );
12931319 };
12941320
1321+ // -----------------------------------------------------------------------
1322+ // Work identity — linking the enterprise assertion the EMA connect consumes.
1323+ //
1324+ // Everything here runs on the SAME machinery as the interactive connect flow:
1325+ // one `oauth_session` row (same table, same TTL, same cleanup), one
1326+ // `buildAuthorizationUrl`, one `exchangeAuthorizationCode`, one callback
1327+ // route. What differs is only what the redeemed grant becomes — custody of a
1328+ // durable subject token instead of a connection.
1329+ // -----------------------------------------------------------------------
1330+
1331+ const loadSessionRow = (state: OAuthState): Effect.Effect<SessionRow | null, StorageFailure> =>
1332+ deps.fuma
1333+ .use("oauth_session.findFirst", (db) =>
1334+ looseDb(db).findFirst("oauth_session", {
1335+ where: (b: any) => b("state", "=", String(state)),
1336+ }),
1337+ )
1338+ .pipe(Effect.map((row) => row as SessionRow | null));
1339+
1340+ /** The default writable store paired with its `set`, or a loud failure. Work
1341+ * identities are credential material; there is no degraded mode where they
1342+ * live elsewhere.
1343+ *
1344+ * Returns a PAIR rather than a narrowed provider so the provider object is
1345+ * passed through untouched — copying it to re-type `set` would silently drop
1346+ * anything a backend carries on a prototype. */
1347+ const requireWritableProvider = (
1348+ purpose: string,
1349+ ): Effect.Effect<
1350+ {
1351+ readonly provider: CredentialProvider;
1352+ readonly set: NonNullable<CredentialProvider["set"]>;
1353+ },
1354+ StorageFailure
1355+ > => {
1356+ const provider = deps.defaultWritableProvider();
1357+ const set = provider?.set;
1358+ if (!provider || set === undefined) {
1359+ return Effect.fail(
1360+ new StorageError({
1361+ message: `No default writable credential provider is registered to ${purpose}.`,
1362+ cause: undefined,
1363+ }),
1364+ );
1365+ }
1366+ return Effect.succeed({ provider, set });
1367+ };
1368+
1369+ /** Read the held record, or null when nothing usable is stored. A value that
1370+ * does not decode reads as ABSENT: the product then offers a link, which
1371+ * overwrites it — the one action that both tells the truth ("nothing usable
1372+ * is held") and repairs the state. */
1373+ const loadWorkIdentity = (
1374+ ref: WorkIdentityRef,
1375+ ): Effect.Effect<WorkIdentityRecord | null, StorageFailure> =>
1376+ Effect.gen(function* () {
1377+ const provider = deps.defaultWritableProvider();
1378+ if (!provider) return null;
1379+ const stored = yield* provider.get(workIdentityItemId(ref));
1380+ if (stored === null) return null;
1381+ return Option.getOrNull(decodeWorkIdentityRecord(stored));
1382+ });
1383+
1384+ const storeWorkIdentity = (
1385+ ref: WorkIdentityRef,
1386+ record: WorkIdentityRecord,
1387+ ): Effect.Effect<void, StorageFailure> =>
1388+ requireWritableProvider("store the enterprise work identity").pipe(
1389+ Effect.flatMap(({ set }) => set(workIdentityItemId(ref), encodeWorkIdentityRecord(record))),
1390+ );
1391+
1392+ const startWorkIdentityLink = (
1393+ input: StartWorkIdentityLinkInput,
1394+ ): Effect.Effect<WorkIdentityLinkStart, WorkIdentityLinkError | StorageFailure> =>
1395+ Effect.gen(function* () {
1396+ const keys = yield* Effect.try({
1397+ try: () => deps.ownedKeys(input.owner),
1398+ catch: (cause) =>
1399+ new StorageError({
1400+ message: "Cannot link a work identity for an owner without a subject",
1401+ cause,
1402+ }),
1403+ });
1404+ const client = yield* loadClient(input.idpClientOwner, input.idpClient);
1405+ if (!client) {
1406+ return yield* new WorkIdentityLinkError({
1407+ message: `Enterprise identity provider OAuth client not found: ${input.idpClient}`,
1408+ });
1409+ }
1410+ // The link RUNS this app's authorization-code flow. An app registered for
1411+ // any other grant cannot serve one, and finding that out at the IdP's
1412+ // authorize endpoint would surface as an opaque provider error page.
1413+ if (client.grant !== "authorization_code") {
1414+ return yield* new WorkIdentityLinkError({
1415+ 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.`,
1416+ });
1417+ }
1418+ const flowRedirectUri = input.redirectUri ?? redirectUri;
1419+ if (flowRedirectUri == null) {
1420+ return yield* new WorkIdentityLinkError({ message: REDIRECT_URI_REQUIRED_MESSAGE });
1421+ }
1422+ const requestedScopes = dedupeScopes(input.scopes ?? DEFAULT_WORK_IDENTITY_SCOPES);
1423+
1424+ const verifier = createPkceCodeVerifier();
1425+ const challenge = yield* Effect.promise(() => createPkceCodeChallenge(verifier));
1426+ const state = OAuthState.make(createOAuthState());
1427+ const providerState = encodeOAuthCallbackState({
1428+ state: String(state),
1429+ orgSlug: deps.callbackStateOrgSlug,
1430+ });
1431+
1432+ yield* deps.fuma.use("oauth_session.create", (db) =>
1433+ looseDb(db).create("oauth_session", {
1434+ tenant: keys.tenant,
1435+ owner: keys.owner,
1436+ subject: keys.subject,
1437+ state: String(state),
1438+ client_slug: String(input.idpClient),
1439+ // No integration, connection or template is involved in a link. The
1440+ // sentinel says so explicitly; the payload below is what completion reads.
1441+ integration: WORK_IDENTITY_SESSION_SENTINEL,
1442+ name: WORK_IDENTITY_SESSION_SENTINEL,
1443+ template: WORK_IDENTITY_SESSION_SENTINEL,
1444+ redirect_url: flowRedirectUri,
1445+ pkce_verifier: verifier,
1446+ identity_label: null,
1447+ payload: {
1448+ kind: WORK_IDENTITY_SESSION_SENTINEL,
1449+ owner: input.owner,
1450+ idpClient: String(input.idpClient),
1451+ idpClientOwner: input.idpClientOwner,
1452+ requestedScopes,
1453+ },
1454+ expires_at: Date.now() + OAUTH2_SESSION_TTL_MS,
1455+ created_at: new Date(),
1456+ }),
1457+ );
1458+
1459+ const authorizationUrl = yield* Effect.try({
1460+ try: () =>
1461+ buildAuthorizationUrl({
1462+ authorizationUrl: client.authorizationUrl,
1463+ clientId: client.clientId,
1464+ redirectUrl: flowRedirectUri,
1465+ scopes: requestedScopes,
1466+ state: providerState,
1467+ codeChallenge: challenge,
1468+ resource: client.resource ?? undefined,
1469+ // The same provider quirks the connect flow needs, for the same
1470+ // reason: without Google's `access_type=offline` there is no refresh
1471+ // token, and a work identity with no refresh token is exactly the
1472+ // hour-long custody this feature exists to avoid.
1473+ extraParams: providerAuthorizeExtras(client.authorizationUrl),
1474+ endpointUrlPolicy: deps.endpointUrlPolicy,
1475+ }),
1476+ catch: (cause) =>
1477+ new WorkIdentityLinkError({
1478+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: surface the URL-construction failure
1479+ message: `Failed to build the work identity authorization URL: ${String(cause)}`,
1480+ }),
1481+ });
1482+
1483+ return { authorizationUrl, state } satisfies WorkIdentityLinkStart;
1484+ }).pipe(
1485+ Effect.withSpan("executor.oauth.work_identity.start", {
1486+ attributes: {
1487+ "executor.tenant": deps.tenant,
1488+ "executor.oauth.client": String(input.idpClient),
1489+ },
1490+ }),
1491+ );
1492+
1493+ const completeWorkIdentityLink = (
1494+ input: CompleteWorkIdentityLinkInput,
1495+ ): Effect.Effect<
1496+ WorkIdentityStatus,
1497+ WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure
1498+ > =>
1499+ Effect.gen(function* () {
1500+ const sessionRow = yield* loadSessionRow(input.state);
1501+ if (!sessionRow) return yield* new OAuthSessionNotFoundError({ state: input.state });
1502+ const link = workIdentitySessionPayloadFrom(sessionPayload(sessionRow.payload));
1503+ if (link === null) {
1504+ // The state belongs to a CONNECT. Refusing here is what stops a link
1505+ // completion from redeeming someone else's authorization code into
1506+ // identity custody, and it is checked rather than assumed even though
1507+ // the callback edge already routes by session kind.
1508+ return yield* new WorkIdentityLinkError({
1509+ message: `OAuth state ${input.state} does not belong to a work identity link.`,
1510+ restartRequired: true,
1511+ });
1512+ }
1513+ const expiresAt = Number(sessionRow.expires_at);
1514+ if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
1515+ yield* deleteSession(input.state);
1516+ return yield* new OAuthSessionNotFoundError({ state: input.state });
1517+ }
1518+ const verifier = sessionRow.pkce_verifier == null ? null : String(sessionRow.pkce_verifier);
1519+ if (verifier === null) {
1520+ return yield* new WorkIdentityLinkError({
1521+ message: `Work identity link ${input.state} is missing its PKCE code verifier; start the link again.`,
1522+ restartRequired: true,
1523+ });
1524+ }
1525+ const client = yield* loadClient(link.idpClientOwner, link.idpClient);
1526+ if (!client) {
1527+ return yield* new WorkIdentityLinkError({
1528+ message: `Enterprise identity provider OAuth client not found: ${link.idpClient}`,
1529+ restartRequired: true,
1530+ });
1531+ }
1532+
1533+ const token = yield* exchangeAuthorizationCode({
1534+ tokenUrl: client.tokenUrl,
1535+ clientId: client.clientId,
1536+ clientSecret: client.clientSecret,
1537+ redirectUrl: String(sessionRow.redirect_url),
1538+ codeVerifier: verifier,
1539+ code: input.code,
1540+ resource: client.resource ?? undefined,
1541+ endpointUrlPolicy: deps.endpointUrlPolicy,
1542+ fetch,
1543+ }).pipe(
1544+ Effect.mapError(
1545+ (cause) =>
1546+ new WorkIdentityLinkError({
1547+ // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message` field
1548+ message: `The enterprise identity provider rejected the sign-in: ${cause.message}`,
1549+ restartRequired: cause.error === "invalid_grant",
1550+ }),
1551+ ),
1552+ );
1553+
1554+ // §4.5: the refresh token is the durable subject and is preferred whenever
1555+ // the IdP issued one. ID-token custody is the recorded degradation for an
1556+ // IdP that issues none — it is stored with its own type and its `exp`, so
1557+ // the product can say when it dies instead of discovering it at renewal.
1558+ const tokenType = workIdentityCustodyType({
1559+ refreshToken: token.refresh_token,
1560+ idToken: token.idToken,
1561+ });
1562+ if (tokenType === null) {
1563+ return yield* new WorkIdentityLinkError({
1564+ message:
1565+ "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.",
1566+ });
1567+ }
1568+ const facts = idTokenAccountFacts(token.idToken);
1569+ const isRefreshCustody = tokenType === "urn:ietf:params:oauth:token-type:refresh_token";
1570+ const ref: WorkIdentityRef = {
1571+ owner: link.owner,
1572+ idpClient: link.idpClient,
1573+ idpClientOwner: link.idpClientOwner,
1574+ };
1575+ const record: WorkIdentityRecord = {
1576+ // SAFETY-adjacent: `workIdentityCustodyType` already established which
1577+ // of the two is present, so the branch below cannot read an absent one.
1578+ token: isRefreshCustody ? (token.refresh_token ?? "") : (token.idToken ?? ""),
1579+ tokenType,
1580+ subject: facts.subject,
1581+ label: facts.label ?? token.idTokenIdentityLabel ?? null,
1582+ linkedAt: Date.now(),
1583+ // A refresh token has no client-visible expiry; that is the property
1584+ // this whole design is buying.
1585+ expiresAt: isRefreshCustody ? null : facts.expiresAt,
1586+ // What the IdP said it granted, falling back to what was asked for when
1587+ // it said nothing. "No scope at all" is null, not an empty string.
1588+ scope: token.scope ?? (link.requestedScopes.join(" ") || null),
1589+ };
1590+ yield* storeWorkIdentity(ref, record);
1591+ yield* deleteSession(input.state);
1592+
1593+ // Enumerable facts only: which custody was taken and whether the account
1594+ // could be named. Never the token, never the account identifier itself.
1595+ yield* Effect.annotateCurrentSpan({
1596+ "executor.oauth.work_identity.custody": isRefreshCustody ? "refresh_token" : "id_token",
1597+ "executor.oauth.work_identity.has_account_claims": facts.subject !== null,
1598+ });
1599+ return workIdentityStatusOf(ref, record);
1600+ }).pipe(
1601+ Effect.withSpan("executor.oauth.work_identity.complete", {
1602+ attributes: {
1603+ "executor.tenant": deps.tenant,
1604+ ...(deps.subject != null ? { "executor.subject": deps.subject } : {}),
1605+ },
1606+ }),
1607+ );
1608+
1609+ const workIdentityStatus = (
1610+ ref: WorkIdentityRef,
1611+ ): Effect.Effect<WorkIdentityStatus, StorageFailure> =>
1612+ loadWorkIdentity(ref).pipe(Effect.map((record) => workIdentityStatusOf(ref, record)));
1613+
1614+ const unlinkWorkIdentity = (ref: WorkIdentityRef): Effect.Effect<void, StorageFailure> => {
1615+ const provider = deps.defaultWritableProvider();
1616+ // Idempotent by construction: a store that cannot delete, or an item that is
1617+ // already gone, both leave the caller with "nothing is held", which is the
1618+ // outcome asked for.
1619+ return provider?.delete === undefined ? Effect.void : provider.delete(workIdentityItemId(ref));
1620+ };
1621+
1622+ /** Which subject token an enterprise-managed connect will present, and who
1623+ * owns it. The ONE place the two custody models diverge; every caller below
1624+ * works off the result rather than re-deciding. */
1625+ const resolveEnterpriseSubject = (
1626+ owner: Owner,
1627+ enterprise: EnterpriseManagedStartInput,
1628+ ): Effect.Effect<
1629+ {
1630+ readonly token: string;
1631+ readonly tokenType: SubjectTokenType;
1632+ readonly source: "caller" | "work-identity";
1633+ },
1634+ OAuthStartError | StorageFailure
1635+ > =>
1636+ Effect.gen(function* () {
1637+ const supplied = enterprise.subjectToken;
1638+ if (supplied !== undefined && supplied.length > 0) {
1639+ return {
1640+ token: supplied,
1641+ tokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE,
1642+ source: "caller" as const,
1643+ };
1644+ }
1645+ const ref: WorkIdentityRef = {
1646+ owner,
1647+ idpClient: enterprise.idpClient,
1648+ idpClientOwner: enterprise.idpClientOwner,
1649+ };
1650+ const record = yield* loadWorkIdentity(ref);
1651+ if (record === null) {
1652+ return yield* new OAuthStartError({
1653+ message:
1654+ "No enterprise work identity is linked for this identity provider. Link your work identity, then connect again.",
1655+ workIdentityLinkRequired: true,
1656+ });
1657+ }
1658+ if (!isWorkIdentityUsable(record)) {
1659+ return yield* new OAuthStartError({
1660+ message:
1661+ "Your enterprise work identity was rejected by the identity provider. Link it again, then connect.",
1662+ workIdentityLinkRequired: true,
1663+ });
1664+ }
1665+ return { token: record.token, tokenType: record.tokenType, source: "work-identity" as const };
1666+ });
1667+
12951668 // -----------------------------------------------------------------------
12961669 // start — begin a flow through a client to mint a connection.
12971670 // -----------------------------------------------------------------------
@@ -1511,13 +1884,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 213 unmodified lines
15111884 }),
15121885 ),
15131886 );
1514− // Resolve the caller's optional assertion type ONCE: the chain sends
1515− // it and the connection persists it, and those two must not be able
1516− // to disagree about what was presented.
1517− const resolvedEnterprise = {
1518− ...enterprise,
1519− subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE,
1520− };
1887+ // Resolve WHICH subject token is presented — and whose it is — ONCE:
1888+ // the chain sends it and the connection persists custody of it, and
1889+ // those two must not be able to disagree about what was presented.
1890+ const subject = yield* resolveEnterpriseSubject(input.owner, enterprise);
15211891 const enterpriseGrant = yield* runEnterpriseManagedAuthorization({
15221892 authorizationServerMetadata: metadata,
15231893 idp: {
@@ -1529,8 +1899,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 5 unmodified lines
15291899 clientId: client.clientId,
15301900 clientSecret: client.clientSecret,
15311901 },
1532− subjectToken: resolvedEnterprise.subjectToken,
1533− subjectTokenType: resolvedEnterprise.subjectTokenType,
1902+ subjectToken: subject.token,
1903+ subjectTokenType: subject.tokenType,
15341904 resource: client.resource,
15351905 scopes: requestedScopes,
15361906 endpointUrlPolicy: deps.endpointUrlPolicy,
@@ -1566,7 +1936,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 29 unmodified lines
15661936 client,
15671937 input.clientOwner,
15681938 enterpriseGrant.grant,
1569− resolvedEnterprise,
1939+ { idpClient: enterprise.idpClient, idpClientOwner: enterprise.idpClientOwner },
1940+ subject,
15701941 metadata.issuer,
15711942 ).pipe(
15721943 Effect.mapError(
@@ -1681,14 +2052,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 108 unmodified lines
16812052 input: OAuthCompleteInput,
16822053 ): Effect.Effect<Connection, OAuthCompleteError | OAuthSessionNotFoundError | StorageFailure> =>
16832054 Effect.gen(function* () {
1684− const sessionRow = yield* deps.fuma.use("oauth_session.findFirst", (db) =>
1685− looseDb(db).findFirst("oauth_session", {
1686− where: (b: any) => b("state", "=", String(input.state)),
1687− }),
1688− );
2055+ const sessionRow = yield* loadSessionRow(input.state);
16892056 if (!sessionRow) {
16902057 return yield* new OAuthSessionNotFoundError({ state: input.state });
16912058 }
2059+ // A work-identity link shares this table and this callback URL. Its
2060+ // integration/name/template columns are sentinels, so completing one HERE
2061+ // would mint a connection out of placeholder text. Refuse explicitly
2062+ // rather than relying on the callback edge having routed correctly.
2063+ if (workIdentitySessionPayloadFrom(sessionPayload(sessionRow.payload)) !== null) {
2064+ return yield* new OAuthCompleteError({
2065+ message: `OAuth state ${input.state} belongs to a work identity link, not a connection.`,
2066+ restartRequired: true,
2067+ });
2068+ }
16922069 const session = {
16932070 owner: String(sessionRow.owner) as Owner,
16942071 clientSlug: OAuthClientSlug.make(String(sessionRow.client_slug)),
@@ -1936,9 +2313,19 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 241 unmodified lines
19362313
19372314 /** Mint a connection from an enterprise-managed grant. Distinct from
19382315 * `mintFromToken` because the material persisted is different: there is no
1939− * refresh token (draft §4.4.3), and the identity assertion takes the refresh
1940− * slot — it is exactly the credential that lets renewal run without the
1941− * user, which is what that slot means. */
2316+ * refresh token (draft §4.4.3), and the SUBJECT TOKEN takes the refresh slot
2317+ * — it is exactly the credential that lets renewal run without the user,
2318+ * which is what that slot means.
2319+ *
2320+ * WHERE that slot points is the whole custody decision:
2321+ *
2322+ * - a CALLER-supplied assertion is copied into the connection's own
2323+ * `:refresh` item. The connection owns it, and nothing else can revive it.
2324+ * - a WORK IDENTITY is not copied at all: the slot points at the shared
2325+ * `work-identity:…` record. N connections, one item — which is what makes
2326+ * one re-link revive all of them, and what makes "this identity died"
2327+ * expressible as a fact about the identity instead of N facts about N
2328+ * connections. */
19422329 const mintEnterpriseManagedConnection = (
19432330 target: {
19442331 readonly owner: Owner;
@@ -1950,30 +2337,45 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 5 unmodified lines
19502337 client: LoadedOAuthClient,
19512338 clientOwner: Owner,
19522339 grant: EnterpriseManagedGrant,
1953− /** The connect request's enterprise inputs with the assertion type already
1954− * resolved — the persisted state records what was actually presented, so
1955− * it must not re-derive a default the chain might have differed on. */
1956− enterprise: EnterpriseManagedStartInput & { readonly subjectTokenType: SubjectTokenType },
2340+ /** Which IdP registration minted the ID-JAG, as named on the connect. */
2341+ idp: Pick<EnterpriseManagedStartInput, "idpClient" | "idpClientOwner">,
2342+ /** The subject actually presented, with its type and its custody already
2343+ * decided — the persisted state records what happened, and must not
2344+ * re-derive a default the chain might have differed on. */
2345+ subject: {
2346+ readonly token: string;
2347+ readonly tokenType: SubjectTokenType;
2348+ readonly source: "caller" | "work-identity";
2349+ },
19572350 /** The Resource Authorization Server's issuer identifier, as discovered. */
19582351 audience: string,
19592352 ): Effect.Effect<Connection, StorageFailure> =>
19602353 Effect.gen(function* () {
1961− const provider = deps.defaultWritableProvider();
1962− if (!provider || !provider.set) {
1963− return yield* new StorageError({
1964− message:
1965− "No default writable credential provider is registered to store the OAuth access token.",
1966− cause: undefined,
1967− });
1968− }
2354+ const { provider, set } = yield* requireWritableProvider("store the OAuth access token");
19692355 const itemId = accessItemId(target.owner, target.integration, target.name);
1970− yield* provider.set(ProviderItemId.make(itemId), grant.token.access_token);
1971− const subjectTokenItemId = refreshItemIdFor(itemId);
1972− yield* provider.set(ProviderItemId.make(subjectTokenItemId), enterprise.subjectToken);
2356+ yield* set(ProviderItemId.make(itemId), grant.token.access_token);
2357+
2358+ const subjectTokenItemId =
2359+ subject.source === "work-identity"
2360+ ? String(
2361+ workIdentityItemId({
2362+ owner: target.owner,
2363+ idpClient: idp.idpClient,
2364+ idpClientOwner: idp.idpClientOwner,
2365+ }),
2366+ )
2367+ : refreshItemIdFor(itemId);
2368+ // Only a caller-supplied assertion is written here. Re-writing the work
2369+ // identity would replace a record (the durable subject plus its account
2370+ // facts and any rejection) with a bare token string.
2371+ if (subject.source === "caller") {
2372+ yield* set(ProviderItemId.make(subjectTokenItemId), subject.token);
2373+ }
19732374
19742375 yield* Effect.annotateCurrentSpan({
19752376 "executor.oauth.has_advertised_expiry": typeof grant.token.expires_in === "number",
19762377 "executor.oauth.enterprise_managed": true,
2378+ "executor.oauth.enterprise_subject_source": subject.source,
19772379 });
19782380 return yield* deps.mintOAuthConnection({
19792381 owner: target.owner,
@@ -1990,14 +2392,42 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 10 unmodified lines
19902392 expiresAt: expiresAtFrom(grant.token),
19912393 oauthScope: grant.scope,
19922394 enterpriseManaged: {
1993− idpClient: enterprise.idpClient,
1994− idpClientOwner: enterprise.idpClientOwner,
2395+ idpClient: idp.idpClient,
2396+ idpClientOwner: idp.idpClientOwner,
19952397 audience,
1996− subjectTokenType: enterprise.subjectTokenType,
2398+ subjectTokenType: subject.tokenType,
2399+ subjectSource: subject.source,
19972400 },
19982401 });
19992402 });
20002403
2404+ // -----------------------------------------------------------------------
2405+ // completeCallback — finish whichever flow this callback belongs to.
2406+ //
2407+ // Both browser flows land on the SAME redirect URI, because an enterprise
2408+ // registers executor's callback with its identity provider once and a second
2409+ // URL would be a second thing to get wrong. The session row is therefore the
2410+ // only trustworthy answer to "what is this?" — not the URL, and not the state
2411+ // envelope (which degrades to the raw state on hosts that route no org slug).
2412+ // -----------------------------------------------------------------------
2413+ const completeCallback = (
2414+ input: OAuthCompleteInput,
2415+ ): Effect.Effect<
2416+ OAuthCallbackCompletion,
2417+ OAuthCompleteError | WorkIdentityLinkError | OAuthSessionNotFoundError | StorageFailure
2418+ > =>
2419+ Effect.gen(function* () {
2420+ const sessionRow = yield* loadSessionRow(input.state);
2421+ if (!sessionRow) return yield* new OAuthSessionNotFoundError({ state: input.state });
2422+ return workIdentitySessionPayloadFrom(sessionPayload(sessionRow.payload)) === null
2423+ ? yield* complete(input).pipe(
2424+ Effect.map((connection) => ({ kind: "connection" as const, connection })),
2425+ )
2426+ : yield* completeWorkIdentityLink({ state: input.state, code: input.code }).pipe(
2427+ Effect.map((workIdentity) => ({ kind: "work-identity" as const, workIdentity })),
2428+ );
2429+ });
2430+
20012431 const deleteSession = (state: OAuthState): Effect.Effect<void, StorageFailure> =>
20022432 deps.fuma
20032433 .use("oauth_session.delete", (db) =>
@@ -2069,7 +2499,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 65 unmodified lines
20692499 listClients,
20702500 start,
20712501 complete,
2502+ completeCallback,
20722503 cancel,
20732504 probe,
2505+ startWorkIdentityLink,
2506+ completeWorkIdentityLink,
2507+ workIdentityStatus,
2508+ unlinkWorkIdentity,
20742509 };
20752510 };
packages/core/sdk/src/oauth-work-identity.test.ts+641−0
@@ -0,0 +1,641 @@
1+// ---------------------------------------------------------------------------
2+// Work identity — acquiring, holding and losing the enterprise assertion that
3+// enterprise-managed authorization consumes.
4+//
5+// `oauth-ema-lifecycle.test.ts` covers the caller-supplied-assertion world: the
6+// connect request carries the assertion, the connection keeps a private copy,
7+// and when that copy dies the CONNECTION is dead. This file covers the world the
8+// console lives in, where the product acquires the assertion itself, and where
9+// the two claims that follow from that are the whole point:
10+//
11+// 1. custody is the IdP REFRESH token (draft §4.5), so renewal outlives the
12+// ID token that would otherwise kill every managed connection within the
13+// hour, and
14+// 2. the identity is SHARED by every connection made from it, so its death is
15+// one fact with one remedy — re-link once — rather than N dead connections
16+// needing N reconnects.
17+//
18+// Both are asserted through the executor's own surfaces and the identity
19+// provider's request ledger, never by reading private state.
20+// ---------------------------------------------------------------------------
21+
22+import { assert, describe, expect, it } from "@effect/vitest";
23+import { Effect, Predicate } from "effect";
24+
25+import {
26+ AuthTemplateSlug,
27+ ConnectionName,
28+ IntegrationSlug,
29+ OAuthClientSlug,
30+ ToolAddress,
31+ ToolName,
32+} from "./ids";
33+import type { OAuthService } from "./oauth-client";
34+import { definePlugin } from "./plugin";
35+import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
36+import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";
37+
38+const INTEG = IntegrationSlug.make("acme");
39+const TEMPLATE = AuthTemplateSlug.make("oauth");
40+const IDP_CLIENT = OAuthClientSlug.make("enterprise-idp");
41+const RESOURCE_CLIENT = OAuthClientSlug.make("mcp-server-app");
42+const CONNECTION = ConnectionName.make("work");
43+const SECOND_CONNECTION = ConnectionName.make("work2");
44+const TOOL = ToolAddress.make("tools.acme.org.work.whoami");
45+const SECOND_TOOL = ToolAddress.make("tools.acme.org.work2.whoami");
46+
47+const CLIENT_AT_IDP = "client-at-idp";
48+const CLIENT_AT_RESOURCE = "client-at-resource";
49+const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token" as const;
50+const ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" as const;
51+
52+/** The enterprise account the IdP fixture signs the user in as. `exp` is far
53+ * enough out that nothing here depends on wall-clock drift; the point of the
54+ * ID-token custody tier is the RECORDED deadline, not reaching it. */
55+const ID_TOKEN_EXP_SECONDS = Math.floor(Date.now() / 1000) + 3600;
56+const ACCOUNT_CLAIMS = {
57+ sub: "00u-enterprise-1",
58+ email: "alice@enterprise.test",
59+ exp: ID_TOKEN_EXP_SECONDS,
60+} as const;
61+
62+const oauthPlugin = definePlugin(() => ({
63+ id: "acme" as const,
64+ storage: () => ({}),
65+ resolveTools: () =>
66+ Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }),
67+ describeAuthMethods: () => [
68+ {
69+ id: "oauth",
70+ label: "OAuth2",
71+ kind: "oauth" as const,
72+ template: String(TEMPLATE),
73+ oauth: { scopes: ["mcp.read"] },
74+ },
75+ ],
76+ invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
77+ extension: (ctx) => ({
78+ seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }),
79+ }),
80+}))();
81+
82+/** A workspace with its OWN credential store. The store must not be shared: a
83+ * work identity is filed under an id derived only from (owner, IdP app), so a
84+ * store shared across tests would let one test's held identity be resolved by
85+ * the next — which is exactly the sharing the feature relies on in production
86+ * and exactly the wrong thing between test cases. */
87+const workspace = () =>
88+ makeTestWorkspaceHarness({ plugins: [memoryCredentialsPlugin(), oauthPlugin] as const });
89+
90+interface EnterpriseServers {
91+ readonly idp: OAuthTestServerShape;
92+ readonly resource: OAuthTestServerShape;
93+}
94+
95+const enterpriseServers = (
96+ options: {
97+ /** Stand in for an identity provider that hands back no refresh token, which
98+ * forces the link onto ID-token custody. */
99+ readonly issueRefreshToken?: boolean;
100+ readonly resourceTokenExpiresInSeconds?: number;
101+ } = {},
102+) =>
103+ Effect.gen(function* () {
104+ const idp = yield* serveOAuthTestServer({
105+ clients: { [CLIENT_AT_IDP]: null },
106+ scopes: ["mcp.read"],
107+ idTokenClaims: ACCOUNT_CLAIMS,
108+ ...(options.issueRefreshToken === undefined
109+ ? {}
110+ : { issueRefreshToken: options.issueRefreshToken }),
111+ enterpriseIdp: { resourceClientIds: { [CLIENT_AT_IDP]: CLIENT_AT_RESOURCE } },
112+ });
113+ const resource = yield* serveOAuthTestServer({
114+ clients: { [CLIENT_AT_RESOURCE]: null },
115+ scopes: ["mcp.read"],
116+ ...(options.resourceTokenExpiresInSeconds === undefined
117+ ? {}
118+ : { tokenExpiresInSeconds: options.resourceTokenExpiresInSeconds }),
119+ enterpriseResourceServer: { trustedIdpIssuer: idp.issuerUrl },
120+ });
121+ return { idp, resource } satisfies EnterpriseServers;
122+ });
123+
124+const registerClients = (createClient: OAuthService["createClient"], servers: EnterpriseServers) =>
125+ Effect.gen(function* () {
126+ yield* createClient({
127+ owner: "org",
128+ slug: IDP_CLIENT,
129+ authorizationUrl: servers.idp.authorizationEndpoint,
130+ tokenUrl: servers.idp.tokenEndpoint,
131+ grant: "authorization_code",
132+ clientId: CLIENT_AT_IDP,
133+ clientSecret: "",
134+ });
135+ yield* createClient({
136+ owner: "org",
137+ slug: RESOURCE_CLIENT,
138+ authorizationUrl: servers.resource.authorizationEndpoint,
139+ tokenUrl: servers.resource.tokenEndpoint,
140+ grant: "id_jag",
141+ clientId: CLIENT_AT_RESOURCE,
142+ clientSecret: "",
143+ resource: servers.resource.mcpResourceUrl,
144+ });
145+ });
146+
147+const WORK_IDENTITY = {
148+ owner: "org",
149+ idpClient: IDP_CLIENT,
150+ idpClientOwner: "org",
151+} as const;
152+
153+/** Drive the whole link the way a browser does: ask for the authorization URL,
154+ * sign in at the identity provider, hand the code back. No private state is
155+ * touched — everything below observes the result through `workIdentityStatus`
156+ * and through what the identity provider was later asked. */
157+const linkWorkIdentity = (oauth: OAuthService, servers: EnterpriseServers) =>
158+ Effect.gen(function* () {
159+ const started = yield* oauth.startWorkIdentityLink(WORK_IDENTITY);
160+ const callback = yield* servers.idp.completeAuthorizationCodeFlow({
161+ authorizationUrl: started.authorizationUrl,
162+ });
163+ return yield* oauth.completeWorkIdentityLink({
164+ state: started.state,
165+ code: callback.code,
166+ });
167+ });
168+
169+/** A connect that names the identity provider but carries NO assertion — the
170+ * console's request. Everything about which subject is presented, and who owns
171+ * it, is resolved server-side. */
172+const connectWithHeldIdentity = (name: ConnectionName = CONNECTION) =>
173+ ({
174+ owner: "org",
175+ client: RESOURCE_CLIENT,
176+ clientOwner: "org",
177+ name,
178+ integration: INTEG,
179+ template: TEMPLATE,
180+ enterprise: { idpClient: IDP_CLIENT, idpClientOwner: "org" },
181+ }) as const;
182+
183+const tokenExchanges = (servers: EnterpriseServers) =>
184+ servers.idp.requests.pipe(
185+ Effect.map((entries) =>
186+ entries
187+ .filter((entry) => entry.path === "/token" && entry.body.includes("token-exchange"))
188+ .map((entry) => new URLSearchParams(entry.body)),
189+ ),
190+ );
191+
192+const healthOf = (
193+ connections: readonly { readonly name: ConnectionName; readonly lastHealth?: unknown }[],
194+ name: ConnectionName,
195+) => connections.find((entry) => String(entry.name) === String(name))?.lastHealth;
196+
197+describe("work identity — linking", () => {
198+ it.effect("takes custody of the identity provider's refresh token, not its ID token", () =>
199+ Effect.scoped(
200+ Effect.gen(function* () {
201+ const servers = yield* enterpriseServers();
202+ const { executor } = yield* workspace();
203+ yield* executor.acme.seed();
204+ yield* registerClients(executor.oauth.createClient, servers);
205+
206+ expect(
207+ (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status,
208+ "nothing is held before the user links",
209+ ).toBe("unlinked");
210+
211+ const linked = yield* linkWorkIdentity(executor.oauth, servers);
212+
213+ assert(linked.status === "linked");
214+ expect(
215+ linked.subjectTokenType,
216+ "draft §4.5: the durable subject is the refresh token — an ID token in custody would strand every managed connection at its first expiry",
217+ ).toBe(REFRESH_TOKEN_TYPE);
218+ expect(
219+ linked.expiresAt,
220+ "a refresh token has no client-visible expiry, and claiming one would be a deadline we invented",
221+ ).toBeNull();
222+ expect(linked.subject, "the account is named from the ID token's claims").toBe(
223+ ACCOUNT_CLAIMS.sub,
224+ );
225+ expect(linked.label).toBe(ACCOUNT_CLAIMS.email);
226+ expect(linked.idpClient).toBe(IDP_CLIENT);
227+ }),
228+ ),
229+ );
230+
231+ it.effect("requests openid and offline_access so both of those facts are obtainable", () =>
232+ Effect.scoped(
233+ Effect.gen(function* () {
234+ const servers = yield* enterpriseServers();
235+ const { executor } = yield* workspace();
236+ yield* executor.acme.seed();
237+ yield* registerClients(executor.oauth.createClient, servers);
238+
239+ const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY);
240+
241+ const scope = new URL(started.authorizationUrl).searchParams.get("scope");
242+ expect(
243+ scope?.split(" ").sort(),
244+ "`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",
245+ ).toEqual(["offline_access", "openid"]);
246+ expect(
247+ new URL(started.authorizationUrl).searchParams.get("code_challenge_method"),
248+ "the link runs the same PKCE authorization-code flow as every other browser flow here",
249+ ).toBe("S256");
250+ }),
251+ ),
252+ );
253+
254+ it.effect(
255+ "records ID-token custody, with its deadline, when the IdP issues no refresh token",
256+ () =>
257+ Effect.scoped(
258+ Effect.gen(function* () {
259+ const servers = yield* enterpriseServers({ issueRefreshToken: false });
260+ const { executor } = yield* workspace();
261+ yield* executor.acme.seed();
262+ yield* registerClients(executor.oauth.createClient, servers);
263+
264+ const linked = yield* linkWorkIdentity(executor.oauth, servers);
265+
266+ assert(linked.status === "linked");
267+ expect(
268+ linked.subjectTokenType,
269+ "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",
270+ ).toBe(ID_TOKEN_TYPE);
271+ expect(
272+ linked.expiresAt,
273+ "and it carries the deadline the product needs to warn before renewal starts failing",
274+ ).toBe(ID_TOKEN_EXP_SECONDS * 1000);
275+ }),
276+ ),
277+ );
278+
279+ it.effect("forgets a linked identity on unlink", () =>
280+ Effect.scoped(
281+ Effect.gen(function* () {
282+ const servers = yield* enterpriseServers();
283+ const { executor } = yield* workspace();
284+ yield* executor.acme.seed();
285+ yield* registerClients(executor.oauth.createClient, servers);
286+ yield* linkWorkIdentity(executor.oauth, servers);
287+
288+ yield* executor.oauth.unlinkWorkIdentity(WORK_IDENTITY);
289+ expect((yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status).toBe("unlinked");
290+
291+ yield* executor.oauth.unlinkWorkIdentity(WORK_IDENTITY);
292+ expect(
293+ (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status,
294+ "unlinking twice is the same outcome as unlinking once",
295+ ).toBe("unlinked");
296+ }),
297+ ),
298+ );
299+
300+ it.effect("refuses to link through an app registered for another grant", () =>
301+ Effect.scoped(
302+ Effect.gen(function* () {
303+ const servers = yield* enterpriseServers();
304+ const { executor } = yield* workspace();
305+ yield* executor.acme.seed();
306+ yield* registerClients(executor.oauth.createClient, servers);
307+
308+ const failure = yield* executor.oauth
309+ .startWorkIdentityLink({ ...WORK_IDENTITY, idpClient: RESOURCE_CLIENT })
310+ .pipe(Effect.flip);
311+
312+ assert(Predicate.isTagged(failure, "WorkIdentityLinkError"));
313+ expect(
314+ failure.message,
315+ "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",
316+ ).toContain("id_jag");
317+ }),
318+ ),
319+ );
320+
321+ it.effect("will not let a link's state be completed as a connection", () =>
322+ Effect.scoped(
323+ Effect.gen(function* () {
324+ const servers = yield* enterpriseServers();
325+ const { executor } = yield* workspace();
326+ yield* executor.acme.seed();
327+ yield* registerClients(executor.oauth.createClient, servers);
328+
329+ const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY);
330+ const callback = yield* servers.idp.completeAuthorizationCodeFlow({
331+ authorizationUrl: started.authorizationUrl,
332+ });
333+
334+ const failure = yield* executor.oauth
335+ .complete({ state: started.state, code: callback.code })
336+ .pipe(Effect.flip);
337+
338+ assert(
339+ Predicate.isTagged(failure, "OAuthCompleteError"),
340+ "a link session carries sentinel integration/name/template columns; minting a connection out of them would produce a connection named after a placeholder",
341+ );
342+ expect(failure.restartRequired).toBe(true);
343+ expect(
344+ (yield* executor.connections.list()).length,
345+ "and nothing was minted on the way to refusing",
346+ ).toBe(0);
347+ }),
348+ ),
349+ );
350+
351+ it.effect("routes a shared callback to the flow its session belongs to", () =>
352+ Effect.scoped(
353+ Effect.gen(function* () {
354+ const servers = yield* enterpriseServers();
355+ const { executor } = yield* workspace();
356+ yield* executor.acme.seed();
357+ yield* registerClients(executor.oauth.createClient, servers);
358+
359+ const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY);
360+ const callback = yield* servers.idp.completeAuthorizationCodeFlow({
361+ authorizationUrl: started.authorizationUrl,
362+ });
363+
364+ const completion = yield* executor.oauth.completeCallback({
365+ state: started.state,
366+ code: callback.code,
367+ });
368+
369+ assert(
370+ completion.kind === "work-identity",
371+ "both browser flows share one redirect URI, so the in-flight session — not the URL — is what says which one came back",
372+ );
373+ expect(completion.workIdentity.status).toBe("linked");
374+ }),
375+ ),
376+ );
377+});
378+
379+describe("work identity — enterprise-managed connect", () => {
380+ it.effect("connects with no assertion on the request, presenting the held refresh token", () =>
381+ Effect.scoped(
382+ Effect.gen(function* () {
383+ const servers = yield* enterpriseServers();
384+ const { executor } = yield* workspace();
385+ yield* executor.acme.seed();
386+ yield* registerClients(executor.oauth.createClient, servers);
387+ yield* linkWorkIdentity(executor.oauth, servers);
388+
389+ const started = yield* executor.oauth.start(connectWithHeldIdentity());
390+
391+ assert(
392+ started.status === "connected",
393+ "a linked user connects an enterprise-managed server with neither an assertion in hand nor a consent screen",
394+ );
395+ const exchanges = yield* tokenExchanges(servers);
396+ expect(exchanges.length).toBe(1);
397+ expect(
398+ exchanges[0]?.get("subject_token_type"),
399+ "the exchange presents the durable subject the link took custody of",
400+ ).toBe(REFRESH_TOKEN_TYPE);
401+
402+ const invoked = (yield* executor.execute(TOOL, {})) as { readonly token: string };
403+ expect(
404+ yield* servers.resource.acceptsAccessToken(invoked.token),
405+ "and the tool call rides the token the chain minted",
406+ ).toBe(true);
407+ }),
408+ ),
409+ );
410+
411+ it.effect("tells an unlinked user to link, rather than failing as a credential problem", () =>
412+ Effect.scoped(
413+ Effect.gen(function* () {
414+ const servers = yield* enterpriseServers();
415+ const { executor } = yield* workspace();
416+ yield* executor.acme.seed();
417+ yield* registerClients(executor.oauth.createClient, servers);
418+
419+ const failure = yield* executor.oauth.start(connectWithHeldIdentity()).pipe(Effect.flip);
420+
421+ assert(Predicate.isTagged(failure, "OAuthStartError"));
422+ expect(
423+ failure.workIdentityLinkRequired,
424+ "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",
425+ ).toBe(true);
426+ expect(
427+ failure.blockedByAdmin,
428+ "nothing was asked of the identity provider, so no administrator refused anything",
429+ ).toBeUndefined();
430+ expect(
431+ (yield* tokenExchanges(servers)).length,
432+ "and no assertion was spent finding that out",
433+ ).toBe(0);
434+ }),
435+ ),
436+ );
437+
438+ it.effect("leaves the explicit-assertion path exactly as it was", () =>
439+ Effect.scoped(
440+ Effect.gen(function* () {
441+ const servers = yield* enterpriseServers();
442+ const { executor } = yield* workspace();
443+ yield* executor.acme.seed();
444+ yield* registerClients(executor.oauth.createClient, servers);
445+
446+ // A headless caller holding its own assertion, with NOTHING linked.
447+ const session = yield* servers.idp.completeAuthorizationCodeTokenFlow({
448+ clientId: CLIENT_AT_IDP,
449+ clientSecret: "",
450+ scopes: ["mcp.read"],
451+ });
452+ const started = yield* executor.oauth.start({
453+ ...connectWithHeldIdentity(),
454+ enterprise: {
455+ idpClient: IDP_CLIENT,
456+ idpClientOwner: "org",
457+ subjectToken: session.accessToken,
458+ subjectTokenType: "urn:ietf:params:oauth:token-type:access_token",
459+ },
460+ });
461+
462+ assert(started.status === "connected");
463+ const exchanges = yield* tokenExchanges(servers);
464+ expect(
465+ exchanges[0]?.get("subject_token_type"),
466+ "what the caller passed is what is presented — resolution never overrides an explicit assertion",
467+ ).toBe("urn:ietf:params:oauth:token-type:access_token");
468+ expect(
469+ (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status,
470+ "and a caller-supplied assertion is the caller's, so it never becomes the user's held identity",
471+ ).toBe("unlinked");
472+ }),
473+ ),
474+ );
475+});
476+
477+describe("work identity — rollout gate composability", () => {
478+ it.effect("gates the connect exactly once and does not gate the link at all", () =>
479+ Effect.scoped(
480+ Effect.gen(function* () {
481+ const servers = yield* enterpriseServers();
482+ const consultations: string[] = [];
483+ const { executor } = yield* makeTestWorkspaceHarness({
484+ plugins: [memoryCredentialsPlugin(), oauthPlugin] as const,
485+ enterpriseManagedRollout: {
486+ decide: (context) =>
487+ Effect.sync(() => {
488+ consultations.push(String(context.integration));
489+ return { kind: "enabled" } as const;
490+ }),
491+ record: () => Effect.void,
492+ },
493+ });
494+ yield* executor.acme.seed();
495+ yield* registerClients(executor.oauth.createClient, servers);
496+
497+ yield* linkWorkIdentity(executor.oauth, servers);
498+ expect(
499+ consultations,
500+ "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",
501+ ).toEqual([]);
502+
503+ yield* executor.oauth.start(connectWithHeldIdentity());
504+ expect(
505+ consultations,
506+ "the connect is still gated, still exactly once, and still before anything leaves the process",
507+ ).toEqual([String(INTEG)]);
508+
509+ yield* executor.execute(TOOL, {});
510+ expect(
511+ consultations,
512+ "and resolving credentials never re-consults it, so the flag can never strand a live connection",
513+ ).toEqual([String(INTEG)]);
514+ }),
515+ ),
516+ );
517+});
518+
519+describe("work identity — lifecycle", () => {
520+ it.effect("renews after the ID token that started the link would have died", () =>
521+ Effect.scoped(
522+ Effect.gen(function* () {
523+ const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
524+ const { executor } = yield* workspace();
525+ yield* executor.acme.seed();
526+ yield* registerClients(executor.oauth.createClient, servers);
527+ yield* linkWorkIdentity(executor.oauth, servers);
528+ yield* executor.oauth.start(connectWithHeldIdentity());
529+
530+ // Kill everything the SSO issued that expires: the access token, and
531+ // with it the ID token minted beside it. Under the old design — an ID
532+ // token in custody — this is precisely the state a connection reaches
533+ // an hour after it was made, and every renewal from here fails.
534+ for (const token of yield* servers.idp.issuedAccessTokens) {
535+ yield* servers.idp.revokeAccessToken(token);
536+ }
537+
538+ const first = (yield* executor.execute(TOOL, {})) as { readonly token: string };
539+ const second = (yield* executor.execute(TOOL, {})) as { readonly token: string };
540+
541+ expect(second.token, "the expiring access token was replaced").not.toBe(first.token);
542+ expect(
543+ yield* servers.resource.acceptsAccessToken(second.token),
544+ "renewal still reaches the identity provider and still comes back with a usable token, because custody is the refresh token",
545+ ).toBe(true);
546+ const exchanges = yield* tokenExchanges(servers);
547+ expect(
548+ exchanges.length,
549+ "every renewal returns to the IdP, so enterprise policy is re-evaluated each time",
550+ ).toBeGreaterThan(1);
551+ expect(
552+ exchanges.every((params) => params.get("subject_token_type") === REFRESH_TOKEN_TYPE),
553+ "and every one of them presents the durable subject",
554+ ).toBe(true);
555+ }),
556+ ),
557+ );
558+
559+ it.effect("turns a rejected identity into ONE re-link that revives every connection", () =>
560+ Effect.scoped(
561+ Effect.gen(function* () {
562+ const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });
563+ const { executor } = yield* workspace();
564+ yield* executor.acme.seed();
565+ yield* registerClients(executor.oauth.createClient, servers);
566+ const linked = yield* linkWorkIdentity(executor.oauth, servers);
567+ assert(linked.status === "linked");
568+ yield* executor.oauth.start(connectWithHeldIdentity());
569+ yield* executor.oauth.start(connectWithHeldIdentity(SECOND_CONNECTION));
570+ yield* executor.execute(TOOL, {});
571+ yield* executor.execute(SECOND_TOOL, {});
572+
573+ // The enterprise ends the user's sessions. Both connections renew from
574+ // the SAME held identity, so both meet it.
575+ expect(
576+ yield* servers.idp.revokeRefreshTokensFor(CLIENT_AT_IDP),
577+ "one link means one durable subject at the identity provider, however many connections were made from it",
578+ ).toBe(1);
579+
580+ const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip);
581+ assert(
582+ Predicate.isTagged(failure, "CredentialResolutionError"),
583+ "a dead identity is a credential verdict, not an execution fault",
584+ );
585+ expect(
586+ failure.workIdentityRelinkRequired,
587+ "the remedy is a re-link, and a console that reads only `reauthRequired` would send the user to reconnect N connections instead",
588+ ).toBe(true);
589+ expect(failure.reauthRequired, "it is still definitive — no retry recovers it").toBe(true);
590+ expect(
591+ failure.blockedByAdmin,
592+ "the subject died; the administrator withdrew nothing",
593+ ).toBeUndefined();
594+
595+ const status = yield* executor.oauth.workIdentityStatus(WORK_IDENTITY);
596+ assert(
597+ status.status === "needs_relink",
598+ "the rejection is recorded on the IDENTITY — one fact, not one per connection",
599+ );
600+ expect(status.revokedReason).toBe("rejected");
601+ expect(
602+ status.label,
603+ "and the account it names survives, so the user knows what to re-link",
604+ ).toBe(ACCOUNT_CLAIMS.email);
605+
606+ const secondFailure = yield* executor.execute(SECOND_TOOL, {}).pipe(Effect.flip);
607+ assert(Predicate.isTagged(secondFailure, "CredentialResolutionError"));
608+ expect(
609+ secondFailure.workIdentityRelinkRequired,
610+ "the second connection reports the same one remedy",
611+ ).toBe(true);
612+
613+ const stalled = yield* executor.connections.list();
614+ expect(
615+ healthOf(stalled, CONNECTION),
616+ "both connections show the problem without waiting for a probe",
617+ ).toMatchObject({ status: "expired" });
618+ expect(healthOf(stalled, SECOND_CONNECTION)).toMatchObject({ status: "expired" });
619+
620+ // ONE re-link. No reconnect, no `oauth.start`, nothing touching either
621+ // connection — which is the claim: a dead work identity must not have
622+ // stamped a reauth verdict onto connections that never lost anything.
623+ const relinked = yield* linkWorkIdentity(executor.oauth, servers);
624+ expect(relinked.status).toBe("linked");
625+
626+ const revivedFirst = (yield* executor.execute(TOOL, {})) as { readonly token: string };
627+ const revivedSecond = (yield* executor.execute(SECOND_TOOL, {})) as {
628+ readonly token: string;
629+ };
630+ expect(
631+ yield* servers.resource.acceptsAccessToken(revivedFirst.token),
632+ "the first connection renews again after the single re-link",
633+ ).toBe(true);
634+ expect(
635+ yield* servers.resource.acceptsAccessToken(revivedSecond.token),
636+ "and so does the second — which is the whole difference between a dead identity and N dead connections",
637+ ).toBe(true);
638+ }),
639+ ),
640+ );
641+});
packages/core/sdk/src/oauth-work-identity.ts+353−0
@@ -0,0 +1,353 @@
1+// ---------------------------------------------------------------------------
2+// Work identity — the acquisition half of MCP Enterprise-Managed Authorization.
3+//
4+// The EMA connect path (`oauth.start` with `enterprise`) presents an identity
5+// assertion to the enterprise IdP and exchanges it for an ID-JAG. Nothing in the
6+// product ACQUIRED that assertion: the connect request simply required the
7+// caller to hand one over. A browser cannot obtain one — the IdP client's secret
8+// is server-side — so in practice there was no way to reach the profile from the
9+// console at all.
10+//
11+// A WORK IDENTITY closes that gap. A user links their enterprise identity ONCE
12+// per (owner, IdP client): executor runs an ordinary authorization-code flow
13+// against the org's registered IdP app, exchanges the code server-side with that
14+// app's credentials, and takes custody of the result. Every later
15+// enterprise-managed connect for that IdP client then resolves the held identity
16+// instead of asking the caller for one.
17+//
18+// WHAT IS HELD, AND WHY IT IS THE REFRESH TOKEN
19+//
20+// draft-ietf-oauth-identity-assertion-authz-grant §4.5 makes the refresh token a
21+// first-class `subject_token` for exactly this reason: an OIDC ID token lives
22+// about an hour, so an ID token in custody turns every renewal after that hour
23+// into "needs SSO" — the connection is dead a lunch break after it was made. A
24+// refresh token is the durable subject, and `SubjectTokenTypeSchema` already
25+// admits it.
26+//
27+// ID-token custody remains as an EXPLICIT, recorded degradation for an IdP that
28+// issues no refresh token (`tokenType` says which is held, and `expiresAt`
29+// carries the deadline). It is a different stored shape, not a silent fallback.
30+//
31+// CUSTODY LAYOUT
32+//
33+// The record lives in the default writable credential provider under a
34+// deterministic item id (see {@link workIdentityItemId}) — the same store the
35+// connection access/refresh material uses, with the same owner partitioning
36+// (`provider-item-owner.ts`). One record, N enterprise-managed connections: the
37+// connections POINT at this item rather than each keeping a private copy, which
38+// is what makes a single re-link recover all of them.
39+// ---------------------------------------------------------------------------
40+
41+import { Option, Schema } from "effect";
42+
43+import { OAuthClientSlug, OAuthState, Owner, ProviderItemId } from "./ids";
44+import { SubjectTokenTypeSchema, type SubjectTokenType } from "./oauth-client";
45+
46+// ---------------------------------------------------------------------------
47+// Addressing
48+// ---------------------------------------------------------------------------
49+
50+/** Which held enterprise identity a request is about: the registered IdP app,
51+ * and the owner partition the identity is filed under.
52+ *
53+ * `owner` is the owner the CONNECTIONS backed by this identity are made under,
54+ * which is also the partition the credential provider files it in — a Personal
55+ * connection resolves the user's own identity, a Workspace connection the
56+ * org-shared one, exactly as an OAuth connection's tokens already work. */
57+export const WorkIdentityRefSchema = Schema.Struct({
58+ owner: Owner,
59+ idpClient: OAuthClientSlug,
60+ idpClientOwner: Owner,
61+}).annotate({
62+ identifier: "WorkIdentityRef",
63+ description:
64+ "The (owner, enterprise identity provider app) pair a held work identity is keyed by.",
65+});
66+export interface WorkIdentityRef extends Schema.Schema.Type<typeof WorkIdentityRefSchema> {}
67+
68+/** The credential-provider item a work identity occupies.
69+ *
70+ * The `work-identity:<owner>:…` grammar is deliberate: `provider-item-owner.ts`
71+ * reads the second segment to decide which partition a provider files the value
72+ * under, so this id must keep that shape or an org identity would be written
73+ * into the acting member's private partition. */
74+export const workIdentityItemId = (ref: WorkIdentityRef): ProviderItemId =>
75+ ProviderItemId.make(`work-identity:${ref.owner}:${ref.idpClientOwner}:${String(ref.idpClient)}`);
76+
77+// ---------------------------------------------------------------------------
78+// The persisted record
79+// ---------------------------------------------------------------------------
80+
81+/** Why a held identity stopped working. `rejected` is the only value the
82+ * renewal path can produce: the IdP refused the stored subject at the token
83+ * exchange (RFC 6749 `invalid_grant`), which is definitive — the user must link
84+ * again. Kept as a closed set so the product can say something specific rather
85+ * than rendering a stored sentence. */
86+export const WorkIdentityRevocationReasonSchema = Schema.Literals(["rejected"]).annotate({
87+ identifier: "WorkIdentityRevocationReason",
88+});
89+export type WorkIdentityRevocationReason = typeof WorkIdentityRevocationReasonSchema.Type;
90+
91+/** Everything custody of a work identity means, as stored in the credential
92+ * provider. The whole record lives in the secret store — not just the token —
93+ * because the account facts beside it (which enterprise account, when it was
94+ * linked) describe a credential and belong under the same protection.
95+ *
96+ * Read it back with {@link decodeWorkIdentityRecord}; never with a cast. */
97+export const WorkIdentityRecordSchema = Schema.Struct({
98+ /** The durable subject presented as `subject_token` on every later exchange:
99+ * the IdP refresh token, or — when the IdP issued none — the ID token. */
100+ token: Schema.String,
101+ /** RFC 8693 §3 type of `token`. Says which of the two custodies is in force. */
102+ tokenType: SubjectTokenTypeSchema,
103+ /** The IdP's `sub` claim for this account. Stable across re-links; the
104+ * identifier support and audit read. */
105+ subject: Schema.NullOr(Schema.String),
106+ /** Display label from the ID token (`email`, else `preferred_username`, else
107+ * `sub`) — what "linked as …" shows. */
108+ label: Schema.NullOr(Schema.String),
109+ /** Epoch ms the link was made. */
110+ linkedAt: Schema.Number,
111+ /** Epoch ms this custody stops working, when it is knowable — the ID token's
112+ * `exp` under ID-token custody. Null for refresh-token custody: a refresh
113+ * token has no client-visible expiry, which is the whole point of holding it. */
114+ expiresAt: Schema.NullOr(Schema.Number),
115+ /** Scope the IdP granted on the link, as it echoed it. Recorded for support;
116+ * never re-requested from here (each connect asks for the server's scopes). */
117+ scope: Schema.NullOr(Schema.String),
118+ /** Set when the IdP rejected this subject. The record is KEPT so the product
119+ * can say "re-link", and so every connection pointing at it short-circuits
120+ * instead of re-spending a doomed exchange. */
121+ revokedAt: Schema.optional(Schema.Number),
122+ revokedReason: Schema.optional(WorkIdentityRevocationReasonSchema),
123+}).annotate({
124+ identifier: "WorkIdentityRecord",
125+ description:
126+ "A user's held enterprise identity: the durable subject token plus the account facts describing it.",
127+});
128+export interface WorkIdentityRecord extends Schema.Schema.Type<typeof WorkIdentityRecordSchema> {}
129+
130+const WorkIdentityRecordFromJson = Schema.fromJsonString(WorkIdentityRecordSchema);
131+
132+/** Parse a stored record. A record that does not decode is treated as ABSENT by
133+ * callers — the product then says "not linked", which is both true (nothing
134+ * usable is held) and recoverable (linking again overwrites it). */
135+export const decodeWorkIdentityRecord = Schema.decodeUnknownOption(WorkIdentityRecordFromJson);
136+
137+/** Serialize a record for the credential provider. */
138+export const encodeWorkIdentityRecord = Schema.encodeSync(WorkIdentityRecordFromJson);
139+
140+/** Whether this record can still be presented to the IdP. */
141+export const isWorkIdentityUsable = (record: WorkIdentityRecord): boolean =>
142+ record.revokedAt === undefined;
143+
144+/** The record with the IdP's rejection recorded. Idempotent: a record already
145+ * marked keeps its FIRST rejection timestamp, so "since when" stays true when
146+ * several connections meet the same dead identity. */
147+export const revokedWorkIdentity = (
148+ record: WorkIdentityRecord,
149+ input: { readonly at: number; readonly reason: WorkIdentityRevocationReason },
150+): WorkIdentityRecord =>
151+ record.revokedAt === undefined
152+ ? { ...record, revokedAt: input.at, revokedReason: input.reason }
153+ : record;
154+
155+// ---------------------------------------------------------------------------
156+// The read model
157+// ---------------------------------------------------------------------------
158+
159+const WorkIdentityAccount = {
160+ /** The IdP's `sub` claim, when the ID token carried one. */
161+ subject: Schema.NullOr(Schema.String),
162+ /** "Linked as …" — the account email, else `preferred_username`, else `sub`. */
163+ label: Schema.NullOr(Schema.String),
164+ linkedAt: Schema.Number,
165+ /** Which custody is in force. `…:refresh_token` survives ID-token expiry;
166+ * `…:id_token` means the IdP issued no refresh token and this link dies at
167+ * `expiresAt`. */
168+ subjectTokenType: SubjectTokenTypeSchema,
169+ expiresAt: Schema.NullOr(Schema.Number),
170+} as const;
171+
172+/** What the console polls. Three states, discriminated by `status`, because the
173+ * product does three different things:
174+ *
175+ * - `unlinked` → offer "Link your work identity".
176+ * - `linked` → show the account; enterprise-managed connects will work.
177+ * - `needs_relink` → an identity IS held but the IdP has rejected it. Every
178+ * enterprise-managed connection backed by it is stalled and ONE re-link
179+ * revives all of them — which is why this is not the same state as
180+ * `unlinked`, and emphatically not the same as a dead connection. */
181+export const WorkIdentityStatusSchema = Schema.Union([
182+ Schema.Struct({
183+ status: Schema.Literal("unlinked"),
184+ idpClient: OAuthClientSlug,
185+ idpClientOwner: Owner,
186+ owner: Owner,
187+ }),
188+ Schema.Struct({
189+ status: Schema.Literal("linked"),
190+ idpClient: OAuthClientSlug,
191+ idpClientOwner: Owner,
192+ owner: Owner,
193+ ...WorkIdentityAccount,
194+ }),
195+ Schema.Struct({
196+ status: Schema.Literal("needs_relink"),
197+ idpClient: OAuthClientSlug,
198+ idpClientOwner: Owner,
199+ owner: Owner,
200+ ...WorkIdentityAccount,
201+ revokedAt: Schema.Number,
202+ revokedReason: WorkIdentityRevocationReasonSchema,
203+ }),
204+]).annotate({
205+ identifier: "WorkIdentityStatus",
206+ description:
207+ "Whether a user holds a usable enterprise identity for an IdP app, and which account it is.",
208+});
209+export type WorkIdentityStatus = typeof WorkIdentityStatusSchema.Type;
210+
211+/** Project a stored record onto the read model. Carries the account facts and
212+ * NOTHING that could stand in for the credential: `token` has no path here. */
213+export const workIdentityStatusOf = (
214+ ref: WorkIdentityRef,
215+ record: WorkIdentityRecord | null,
216+): WorkIdentityStatus => {
217+ if (record === null) {
218+ return {
219+ status: "unlinked",
220+ owner: ref.owner,
221+ idpClient: ref.idpClient,
222+ idpClientOwner: ref.idpClientOwner,
223+ };
224+ }
225+ const account = {
226+ owner: ref.owner,
227+ idpClient: ref.idpClient,
228+ idpClientOwner: ref.idpClientOwner,
229+ subject: record.subject,
230+ label: record.label,
231+ linkedAt: record.linkedAt,
232+ subjectTokenType: record.tokenType,
233+ expiresAt: record.expiresAt,
234+ } as const;
235+ return record.revokedAt === undefined
236+ ? { status: "linked", ...account }
237+ : {
238+ status: "needs_relink",
239+ ...account,
240+ revokedAt: record.revokedAt,
241+ revokedReason: record.revokedReason ?? "rejected",
242+ };
243+};
244+
245+// ---------------------------------------------------------------------------
246+// Flow inputs
247+// ---------------------------------------------------------------------------
248+
249+/** The default scope set a link requests. `openid` is what makes the token
250+ * endpoint return an ID token, which is the only place the account facts come
251+ * from; `offline_access` is how an OIDC provider is asked for the refresh token
252+ * this whole design holds. Deliberately NOT narrowed against the IdP's
253+ * advertised `scopes_supported`: both are standard OIDC scopes that many
254+ * authorization servers decline to enumerate, and dropping either would quietly
255+ * cost the link its account facts or its durability. An IdP that spells them
256+ * differently is served by the explicit `scopes` override. */
257+export const DEFAULT_WORK_IDENTITY_SCOPES: readonly string[] = ["openid", "offline_access"];
258+
259+export interface StartWorkIdentityLinkInput extends WorkIdentityRef {
260+ /** Replace {@link DEFAULT_WORK_IDENTITY_SCOPES} outright. An override, not an
261+ * addition: an IdP that rejects `offline_access` needs the default GONE, not
262+ * supplemented. Omit unless the IdP requires it. */
263+ readonly scopes?: readonly string[];
264+ /** Browser-facing callback for this link. Defaults to the executor's
265+ * configured work-identity callback. */
266+ readonly redirectUri?: string | null;
267+}
268+
269+export interface CompleteWorkIdentityLinkInput {
270+ readonly state: OAuthState;
271+ readonly code: string;
272+}
273+
274+// ---------------------------------------------------------------------------
275+// The in-flight session
276+//
277+// A link reuses `oauth_session` — the same table, TTL and cleanup the connect
278+// flow uses — rather than growing a parallel one. What distinguishes the two is
279+// the payload, and it is CHECKED rather than assumed: a work-identity state
280+// handed to `complete` must not be able to mint a connection out of the sentinel
281+// columns, and a connect state handed to `completeWorkIdentityLink` must not be
282+// able to redeem a code into someone's identity custody.
283+// ---------------------------------------------------------------------------
284+
285+/** Value written to `oauth_session`'s integration/name/template columns for a
286+ * link. They are non-null and mean nothing here — a link targets no
287+ * integration — so they carry a self-describing sentinel instead of an empty
288+ * string that would read as data. The payload is what completion parses. */
289+export const WORK_IDENTITY_SESSION_SENTINEL = "work-identity";
290+
291+export const WorkIdentitySessionPayloadSchema = Schema.Struct({
292+ kind: Schema.Literal("work-identity"),
293+ owner: Owner,
294+ idpClient: OAuthClientSlug,
295+ idpClientOwner: Owner,
296+ /** What the authorize request asked for; the recorded-scope fallback when the
297+ * IdP's token response omits `scope`. */
298+ requestedScopes: Schema.Array(Schema.String),
299+}).annotate({ identifier: "WorkIdentitySessionPayload" });
300+export interface WorkIdentitySessionPayload extends Schema.Schema.Type<
301+ typeof WorkIdentitySessionPayloadSchema
302+> {}
303+
304+const decodeWorkIdentitySessionPayload = Schema.decodeUnknownOption(
305+ WorkIdentitySessionPayloadSchema,
306+);
307+
308+/** The link this session belongs to, or null when the session is not one. The
309+ * ONLY way either completion path decides which flow it is looking at. */
310+export const workIdentitySessionPayloadFrom = (
311+ payload: unknown,
312+): WorkIdentitySessionPayload | null => Option.getOrNull(decodeWorkIdentitySessionPayload(payload));
313+
314+// ---------------------------------------------------------------------------
315+// Errors
316+// ---------------------------------------------------------------------------
317+
318+/** A work-identity link could not be started or completed. Deliberately its own
319+ * tag rather than an `OAuthCompleteError`: nothing about it concerns a
320+ * connection, and a console that renders "could not connect" for a failed link
321+ * sends the user to the wrong place. */
322+export class WorkIdentityLinkError extends Schema.TaggedErrorClass<WorkIdentityLinkError>()(
323+ "WorkIdentityLinkError",
324+ {
325+ message: Schema.String,
326+ /** True when the flow cannot be resumed and the user must start the link
327+ * again (an expired or already-redeemed authorization). */
328+ restartRequired: Schema.optional(Schema.Boolean),
329+ },
330+) {
331+ readonly __executorUserActionable = true;
332+ readonly code = "work_identity_link_error";
333+
334+ get userMessage(): string {
335+ return this.message;
336+ }
337+}
338+
339+/** The subject-token type a link takes custody of, given what the IdP returned.
340+ * Refresh token when there is one (§4.5); ID token otherwise, which is custody
341+ * of something that expires and is recorded as such. */
342+export const workIdentityCustodyType = (input: {
343+ readonly refreshToken: string | undefined;
344+ readonly idToken: string | undefined;
345+}): SubjectTokenType | null => {
346+ if (input.refreshToken !== undefined && input.refreshToken.length > 0) {
347+ return "urn:ietf:params:oauth:token-type:refresh_token";
348+ }
349+ if (input.idToken !== undefined && input.idToken.length > 0) {
350+ return "urn:ietf:params:oauth:token-type:id_token";
351+ }
352+ return null;
353+};
packages/core/sdk/src/provider-item-owner.ts+6−0
@@ -6,6 +6,7 @@ · 5 unmodified lines
66 // connection:<owner>:<integration>:<name>:<variable>
77 // oauth:<owner>:<integration>:<name>[:refresh]
88 // oauth-client:<owner>:<slug>:secret
9+// work-identity:<owner>:<idp-client-owner>:<idp-client>
910 //
1011 // Credential providers file plugin-storage rows by THIS owner, not the acting
1112 // caller's binding — an org connection whose OAuth consent completes in one
@@ -21,6 +22,11 @@ export const OWNER_SCOPED_ITEM_ID_PREFIXES: ReadonlySet<string> = new Set([ · 9 unmodified lines
2122 "connection",
2223 "oauth",
2324 "oauth-client",
25+ // A held enterprise identity is filed under the owner its enterprise-managed
26+ // connections are made under, for the same reason (#950, #1453): a Workspace
27+ // connection linked in one member's browser must stay resolvable by the rest
28+ // of the org, and a Personal one must stay private to its subject.
29+ "work-identity",
2430 ]);
2531
2632 /** The owner a logical item id embeds, or null for ids that carry none
packages/core/sdk/src/shared.ts+17−0
@@ -173,8 +173,25 @@ export { · 172 unmodified lines
173173 OAuthProbeError,
174174 OAuthRegisterDynamicError,
175175 OAuthSessionNotFoundError,
176+ type OAuthCallbackCompletion,
177+ type WorkIdentityLinkStart,
176178 } from "./oauth-client";
177179
180+// Work-identity wire contracts (the link flow's payloads, status projection and
181+// tagged error). The persisted record and its custody rules stay server-only.
182+export {
183+ DEFAULT_WORK_IDENTITY_SCOPES,
184+ WorkIdentityLinkError,
185+ WorkIdentityRefSchema,
186+ WorkIdentityRevocationReasonSchema,
187+ WorkIdentityStatusSchema,
188+ type CompleteWorkIdentityLinkInput,
189+ type StartWorkIdentityLinkInput,
190+ type WorkIdentityRef,
191+ type WorkIdentityRevocationReason,
192+ type WorkIdentityStatus,
193+} from "./oauth-work-identity";
194+
178195 // Wire-level HTTP error schema for plugin HttpApiGroup definitions.
179196 export { InternalError } from "./api-errors";
180197
packages/core/sdk/src/testing/oauth-test-server.ts+46−11
@@ -64,6 +64,12 @@ export interface OAuthTestServerOptions { · 63 unmodified lines
6464 readonly scopes?: readonly string[];
6565 readonly omitTokenResponseScopes?: readonly string[];
6666 readonly supportRefresh?: boolean;
67+ /** Whether the authorization-code grant ISSUES a refresh token at all.
68+ * Default true. Set false to stand in for an identity provider that hands
69+ * back only an access + ID token, which is the case that forces work-identity
70+ * custody onto the (expiring) ID token. Distinct from `supportRefresh`, which
71+ * governs whether an ISSUED refresh token can still be redeemed. */
72+ readonly issueRefreshToken?: boolean;
6773 readonly tokenExpiresInSeconds?: number;
6874 readonly invalidRefreshTokenDescription?: string;
6975 /** RFC 6749 error code returned when a refresh-token grant is rejected.
@@ -152,6 +158,15 @@ export interface OAuthTestServerShape { · 82 unmodified lines
152158 * fixture rejects the exchange afterwards exactly as it would for an expired
153159 * or revoked assertion. */
154160 readonly revokeAccessToken: (token: string) => Effect.Effect<void>;
161+ /** Stop honouring every refresh token this server issued to a client, and
162+ * report how many that was. Models the enterprise-side action a client cannot
163+ * see: an administrator ends the user's sessions, and the next ID-JAG exchange
164+ * presenting one of those refresh tokens is rejected as `invalid_grant`.
165+ *
166+ * Keyed by CLIENT rather than by token value on purpose — a test driving this
167+ * holds no refresh token (custody is the server's), so revoking by value would
168+ * force it to reach into private state to find one. */
169+ readonly revokeRefreshTokensFor: (clientId: string) => Effect.Effect<number>;
155170 /** Start (or stop) refusing every RFC 8693 exchange with this §5.2 error.
156171 * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives
157172 * the case that only exists over time — an administrator withdrawing access
@@ -528,6 +543,7 @@ export const serveOAuthTestServer = ( · 370 unmodified lines
528543 ...(options.users ?? {}),
529544 };
530545 const supportRefresh = options.supportRefresh ?? true;
546+ const issueRefreshToken = options.issueRefreshToken ?? true;
531547 const tokenExpiresInSeconds = options.tokenExpiresInSeconds ?? 3600;
532548 const invalidRefreshTokenDescription =
533549 options.invalidRefreshTokenDescription ?? "Unknown refresh token";
@@ -791,20 +807,22 @@ export const serveOAuthTestServer = ( · 257 unmodified lines
791807 }
792808 authorizationCodes.delete(code);
793809 const accessToken = `at_${randomUUID()}`;
794− const refreshToken = `rt_${randomUUID()}`;
810+ const refreshToken = issueRefreshToken ? `rt_${randomUUID()}` : null;
795811 yield* Ref.update(issuedAccessTokens, (tokens) => new Set([...tokens, accessToken]));
796− refreshTokens.set(refreshToken, {
797− clientId,
798− username: record.username,
799− scope: record.scope,
800− resource: record.resource,
801− });
812+ if (refreshToken !== null) {
813+ refreshTokens.set(refreshToken, {
814+ clientId,
815+ username: record.username,
816+ scope: record.scope,
817+ resource: record.resource,
818+ });
819+ }
802820 const scope = tokenResponseScope(record.scope);
803821 return jsonResponse(
804822 200,
805823 {
806824 access_token: accessToken,
807− refresh_token: refreshToken,
825+ ...(refreshToken === null ? {} : { refresh_token: refreshToken }),
808826 token_type: "Bearer",
809827 expires_in: tokenExpiresInSeconds,
810828 ...(scope ? { scope } : {}),
@@ -907,9 +925,18 @@ export const serveOAuthTestServer = ( · 96 unmodified lines
907925 if (denial) {
908926 return oauthError(400, denial.error, denial.errorDescription);
909927 }
910− const subjectAccepted = yield* Ref.get(issuedAccessTokens).pipe(
911− Effect.map((tokens) => tokens.has(subjectToken)),
912− );
928+ // draft §4.5: a REFRESH token is a first-class subject, and it is
929+ // VALIDATED, NOT CONSUMED — the exchange is not a refresh-token
930+ // grant and must not rotate the client's durable subject out from
931+ // under it. (Matches the Okta emulator's behavior, which this
932+ // fixture stands in for in the hermetic tests.) A refresh token is
933+ // also only acceptable from the client it was issued to.
934+ const subjectAccepted =
935+ subjectTokenType === "urn:ietf:params:oauth:token-type:refresh_token"
936+ ? refreshTokens.get(subjectToken)?.clientId === clientId
937+ : yield* Ref.get(issuedAccessTokens).pipe(
938+ Effect.map((tokens) => tokens.has(subjectToken)),
939+ );
913940 if (!subjectAccepted) {
914941 return oauthError(
915942 400,
@@ -1069,6 +1096,14 @@ export const serveOAuthTestServer = ( · 153 unmodified lines
10691096 next.delete(token);
10701097 return next;
10711098 }),
1099+ revokeRefreshTokensFor: (clientId) =>
1100+ Effect.sync(() => {
1101+ const doomed = [...refreshTokens.entries()].filter(
1102+ ([, record]) => record.clientId === clientId,
1103+ );
1104+ for (const [token] of doomed) refreshTokens.delete(token);
1105+ return doomed.length;
1106+ }),
10721107 setTokenExchangeDenial: (denial) => Ref.set(tokenExchangeDenial, denial),
10731108 acceptsAuthorizationHeader: (authorization) => {
10741109 const token = authorization?.replace(/^Bearer\s+/i, "");