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
315 type OAuthProbeInput,315 type OAuthProbeInput,
316 type OAuthProbeResult,316 type OAuthProbeResult,
317 type OAuthService,317 type OAuthService,
318 type OAuthCallbackCompletion,
319 type WorkIdentityLinkStart,
318} from "./oauth-client";320} from "./oauth-client";
319 321
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.
325export {
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
320// The enterprise-managed rollout PORT (not its implementation): hosts that342// The enterprise-managed rollout PORT (not its implementation): hosts that
321// operate a feature-flag service implement this and hand it to343// operate a feature-flag service implement this and hand it to
322// `createExecutor`. Core depends on no vendor.344// `createExecutor`. Core depends on no vendor.
packages/core/sdk/src/oauth-client.ts+100−9
@@ -12,6 +12,13 @@ import { · 11 unmodified lines
12 OAuthState,12 OAuthState,
13 Owner,13 Owner,
14} from "./ids";14} from "./ids";
15import type {
16 CompleteWorkIdentityLinkInput,
17 StartWorkIdentityLinkInput,
18 WorkIdentityLinkError,
19 WorkIdentityRef,
20 WorkIdentityStatus,
21} from "./oauth-work-identity";
15 22
16/** RFC 8693 §3 security token type identifiers usable as a `subject_token_type`23/** RFC 8693 §3 security token type identifiers usable as a `subject_token_type`
17 * when exchanging an enterprise identity assertion for an ID-JAG. The id-jag24 * when exchanging an enterprise identity assertion for an ID-JAG. The id-jag
@@ -277,9 +284,19 @@ export interface OAuthStartInput { · 259 unmodified lines
277}284}
278 285
279/** What an enterprise-managed connect needs beyond the ordinary start inputs.286/** What an enterprise-managed connect needs beyond the ordinary start inputs.
280 * The subject token is supplied by the caller because "where the identity287 *
281 * assertion comes from" is a host concern: a desktop app holds its own SSO288 * Two ways to say who is connecting, and the difference is custody:
282 * tokens, a hosted deployment holds the session's.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.
283 *300 *
284 * Declared as a Schema because this shape crosses the HTTP boundary: the API's301 * Declared as a Schema because this shape crosses the HTTP boundary: the API's
285 * `oauth.start` payload embeds THIS schema rather than restating its fields. */302 * `oauth.start` payload embeds THIS schema rather than restating its fields. */
@@ -287,19 +304,31 @@ export const EnterpriseManagedStartInputSchema = Schema.Struct({ · 1 unmodified line
287 /** `oauth_client` slug of the client's registration at the enterprise IdP. */304 /** `oauth_client` slug of the client's registration at the enterprise IdP. */
288 idpClient: OAuthClientSlug,305 idpClient: OAuthClientSlug,
289 idpClientOwner: Owner,306 idpClientOwner: Owner,
290 /** The identity assertion from single sign-on with the IdP (an OIDC ID token307 /** The identity assertion from single sign-on with the IdP. OMIT to use the
291 * by default). Persisted through the credential provider so token renewal308 * work identity the user already linked for `idpClient`; supply one to make
292 * needs no further user interaction. */309 * the caller the source, in which case the connection takes custody of
293 subjectToken: Schema.String,310 * exactly this value. */
294 /** RFC 8693 §3 type of `subjectToken`. Defaults to an OIDC ID token. */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. */
295 subjectTokenType: Schema.optional(SubjectTokenTypeSchema),315 subjectTokenType: Schema.optional(SubjectTokenTypeSchema),
296}).annotate({316}).annotate({
297 identifier: "EnterpriseManagedStartInput",317 identifier: "EnterpriseManagedStartInput",
298 description:318 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.",
300});320});
301export type EnterpriseManagedStartInput = typeof EnterpriseManagedStartInputSchema.Type;321export type EnterpriseManagedStartInput = typeof EnterpriseManagedStartInputSchema.Type;
302 322
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. */
328export type OAuthCallbackCompletion =
329 | { readonly kind: "connection"; readonly connection: Connection }
330 | { readonly kind: "work-identity"; readonly workIdentity: WorkIdentityStatus };
331
303export interface OAuthCompleteInput {332export interface OAuthCompleteInput {
304 readonly state: OAuthState;333 readonly state: OAuthState;
305 readonly code: string;334 readonly code: string;
@@ -377,6 +406,13 @@ export class OAuthStartError · 71 unmodified lines
377 * token-endpoint refusal. A typed field rather than message text so406 * token-endpoint refusal. A typed field rather than message text so
378 * telemetry and support tooling read the verdict structurally. */407 * telemetry and support tooling read the verdict structurally. */
379 oauthErrorCode: Schema.optional(Schema.String),408 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),
380 })416 })
381 implements UserActionableError417 implements UserActionableError
382{418{
@@ -467,8 +503,63 @@ export interface OAuthService { · 84 unmodified lines
467 readonly complete: (503 readonly complete: (
468 input: OAuthCompleteInput,504 input: OAuthCompleteInput,
469 ) => Effect.Effect<Connection, OAuthCompleteError | OAuthSessionNotFoundError | StorageFailure>;505 ) => 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 >;
470 readonly cancel: (state: OAuthState) => Effect.Effect<void, StorageFailure>;517 readonly cancel: (state: OAuthState) => Effect.Effect<void, StorageFailure>;
471 readonly probe: (518 readonly probe: (
472 input: OAuthProbeInput,519 input: OAuthProbeInput,
473 ) => Effect.Effect<OAuthProbeResult, OAuthProbeError | StorageFailure>;520 ) => 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. */
562export interface WorkIdentityLinkStart {
563 readonly authorizationUrl: string;
564 readonly state: OAuthState;
474}565}
packages/core/sdk/src/oauth-ema.ts+16−0
@@ -64,6 +64,22 @@ export const EnterpriseManagedConnectionStateSchema = Schema.Struct({ · 63 unmodified lines
64 * its RFC 8414 metadata when the connection was made. */64 * its RFC 8414 metadata when the connection was made. */
65 audience: Schema.String,65 audience: Schema.String,
66 subjectTokenType: SubjectTokenTypeSchema,66 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"])),
67}).annotate({83}).annotate({
68 identifier: "EnterpriseManagedConnectionState",84 identifier: "EnterpriseManagedConnectionState",
69 description:85 description:
packages/core/sdk/src/oauth-helpers.ts+45−3
@@ -48,6 +48,19 @@ export type OAuth2TokenResponse = { · 47 unmodified lines
48 readonly expires_in?: number;48 readonly expires_in?: number;
49 readonly scope?: string;49 readonly scope?: string;
50 readonly idTokenIdentityLabel?: string;50 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;
51};64};
52 65
53// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
@@ -720,9 +733,33 @@ export const idTokenIdentityLabel = (idToken: string | undefined): string | unde · 666 unmodified lines
720 );733 );
721};734};
722 735
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). */
742export 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
723type StrippedTokenResponse = {759type StrippedTokenResponse = {
724 readonly response: Response;760 readonly response: Response;
725 readonly idTokenIdentityLabel?: string;761 readonly idTokenIdentityLabel?: string;
762 readonly idToken?: string;
726};763};
727 764
728const NestedAuthedUserScope = Schema.Struct({765const NestedAuthedUserScope = Schema.Struct({
@@ -791,6 +828,7 @@ const stripIdToken = async (response: Response): Promise<StrippedTokenResponse> · 62 unmodified lines
791 headers: response.headers,828 headers: response.headers,
792 }),829 }),
793 ...(label ? { idTokenIdentityLabel: label } : {}),830 ...(label ? { idTokenIdentityLabel: label } : {}),
831 ...(typeof idToken === "string" && idToken.length > 0 ? { idToken } : {}),
794 };832 };
795};833};
796 834
@@ -817,9 +855,13 @@ const processTokenEndpointResponse = async ( · 20 unmodified lines
817 scope: providerUserGrant.scope,855 scope: providerUserGrant.scope,
818 }856 }
819 : parsed;857 : parsed;
820 return stripped.idTokenIdentityLabel858 return {
821 ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel }859 ...token,
822 : token;860 ...(stripped.idTokenIdentityLabel === undefined
861 ? {}
862 : { idTokenIdentityLabel: stripped.idTokenIdentityLabel }),
863 ...(stripped.idToken === undefined ? {} : { idToken: stripped.idToken }),
864 };
823};865};
824 866
825// ---------------------------------------------------------------------------867// ---------------------------------------------------------------------------
packages/core/sdk/src/oauth-service.ts+484−49
@@ -44,6 +44,8 @@ import { · 43 unmodified lines
44 type CreateOAuthClientInput,44 type CreateOAuthClientInput,
45 type EnterpriseManagedStartInput,45 type EnterpriseManagedStartInput,
46 type FirstPartyOAuthClientConfig,46 type FirstPartyOAuthClientConfig,
47 type OAuthCallbackCompletion,
48 type WorkIdentityLinkStart,
47 type OAuthClientOrigin,49 type OAuthClientOrigin,
48 type OAuthClientSummary,50 type OAuthClientSummary,
49 type OAuthCompleteInput,51 type OAuthCompleteInput,
@@ -75,6 +77,23 @@ import { · 25 unmodified lines
75 type EnterpriseManagedRolloutDecision,77 type EnterpriseManagedRolloutDecision,
76 type EnterpriseManagedRolloutEvent,78 type EnterpriseManagedRolloutEvent,
77} from "./oauth-ema";79} from "./oauth-ema";
80import {
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";
78import {97import {
79 assertSupportedOAuthEndpointUrl,98 assertSupportedOAuthEndpointUrl,
80 buildAuthorizationUrl,99 buildAuthorizationUrl,
@@ -84,6 +103,7 @@ import { · 3 unmodified lines
84 createPkceCodeVerifier,103 createPkceCodeVerifier,
85 exchangeAuthorizationCode,104 exchangeAuthorizationCode,
86 exchangeClientCredentials,105 exchangeClientCredentials,
106 idTokenAccountFacts,
87 isLoopbackHttpUrl,107 isLoopbackHttpUrl,
88 rebindTokenEndpointHostToCallbackDomain,108 rebindTokenEndpointHostToCallbackDomain,
89 type OAuth2TokenResponse,109 type OAuth2TokenResponse,
@@ -343,16 +363,25 @@ export const missingGrantedOAuthScopes = ( · 253 unmodified lines
343 363
344const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);364const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
345 365
346/** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The366/** The stored `oauth_session` row, as this module reads it. `looseDb` returns an
347 * jsonColumn may surface as a parsed object (in-memory backends) or a JSON367 * untyped record; naming the columns we touch keeps the reads honest without
348 * string (serialized backends); decode strings before reading. Returns `null`368 * claiming a decoded row. */
349 * for legacy sessions written before `requestedScopes` was persisted, so369type SessionRow = Readonly<Record<string, unknown>>;
350 * `complete` can fall back to the client's scopes. */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. */
375const 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. */
351const requestedScopesFromPayload = (payload: unknown): readonly string[] | null => {383const requestedScopesFromPayload = (payload: unknown): readonly string[] | null => {
352 const decoded =384 const decoded = sessionPayload(payload);
353 typeof payload === "string"
354 ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload))
355 : payload;
356 if (decoded === null || typeof decoded !== "object") return null;385 if (decoded === null || typeof decoded !== "object") return null;
357 const value = (decoded as Record<string, unknown>).requestedScopes;386 const value = (decoded as Record<string, unknown>).requestedScopes;
358 return Array.isArray(value) ? value.filter((s): s is string => typeof s === "string") : null;387 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
362 * (same-owner connects, or sessions written before this field), so `complete`391 * (same-owner connects, or sessions written before this field), so `complete`
363 * falls back to the session owner. */392 * falls back to the session owner. */
364const clientOwnerFromPayload = (payload: unknown): Owner | null => {393const clientOwnerFromPayload = (payload: unknown): Owner | null => {
365 const decoded =394 const decoded = sessionPayload(payload);
366 typeof payload === "string"
367 ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload))
368 : payload;
369 if (decoded === null || typeof decoded !== "object") return null;395 if (decoded === null || typeof decoded !== "object") return null;
370 const value = (decoded as Record<string, unknown>).clientOwner;396 const value = (decoded as Record<string, unknown>).clientOwner;
371 return value === "user" || value === "org" ? value : null;397 return value === "user" || value === "org" ? value : null;
@@ -1292,6 +1318,353 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 920 unmodified lines
1292 );1318 );
1293 };1319 };
1294 1320
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
1295 // -----------------------------------------------------------------------1668 // -----------------------------------------------------------------------
1296 // start — begin a flow through a client to mint a connection.1669 // start — begin a flow through a client to mint a connection.
1297 // -----------------------------------------------------------------------1670 // -----------------------------------------------------------------------
@@ -1511,13 +1884,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 213 unmodified lines
1511 }),1884 }),
1512 ),1885 ),
1513 );1886 );
1514 // Resolve the caller's optional assertion type ONCE: the chain sends1887 // Resolve WHICH subject token is presented — and whose it is — ONCE:
1515 // it and the connection persists it, and those two must not be able1888 // the chain sends it and the connection persists custody of it, and
1516 // to disagree about what was presented.1889 // those two must not be able to disagree about what was presented.
1517 const resolvedEnterprise = {1890 const subject = yield* resolveEnterpriseSubject(input.owner, enterprise);
1518 ...enterprise,
1519 subjectTokenType: enterprise.subjectTokenType ?? DEFAULT_SUBJECT_TOKEN_TYPE,
1520 };
1521 const enterpriseGrant = yield* runEnterpriseManagedAuthorization({1891 const enterpriseGrant = yield* runEnterpriseManagedAuthorization({
1522 authorizationServerMetadata: metadata,1892 authorizationServerMetadata: metadata,
1523 idp: {1893 idp: {
@@ -1529,8 +1899,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 5 unmodified lines
1529 clientId: client.clientId,1899 clientId: client.clientId,
1530 clientSecret: client.clientSecret,1900 clientSecret: client.clientSecret,
1531 },1901 },
1532 subjectToken: resolvedEnterprise.subjectToken,1902 subjectToken: subject.token,
1533 subjectTokenType: resolvedEnterprise.subjectTokenType,1903 subjectTokenType: subject.tokenType,
1534 resource: client.resource,1904 resource: client.resource,
1535 scopes: requestedScopes,1905 scopes: requestedScopes,
1536 endpointUrlPolicy: deps.endpointUrlPolicy,1906 endpointUrlPolicy: deps.endpointUrlPolicy,
@@ -1566,7 +1936,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 29 unmodified lines
1566 client,1936 client,
1567 input.clientOwner,1937 input.clientOwner,
1568 enterpriseGrant.grant,1938 enterpriseGrant.grant,
1569 resolvedEnterprise,1939 { idpClient: enterprise.idpClient, idpClientOwner: enterprise.idpClientOwner },
1940 subject,
1570 metadata.issuer,1941 metadata.issuer,
1571 ).pipe(1942 ).pipe(
1572 Effect.mapError(1943 Effect.mapError(
@@ -1681,14 +2052,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 108 unmodified lines
1681 input: OAuthCompleteInput,2052 input: OAuthCompleteInput,
1682 ): Effect.Effect<Connection, OAuthCompleteError | OAuthSessionNotFoundError | StorageFailure> =>2053 ): Effect.Effect<Connection, OAuthCompleteError | OAuthSessionNotFoundError | StorageFailure> =>
1683 Effect.gen(function* () {2054 Effect.gen(function* () {
1684 const sessionRow = yield* deps.fuma.use("oauth_session.findFirst", (db) =>2055 const sessionRow = yield* loadSessionRow(input.state);
1685 looseDb(db).findFirst("oauth_session", {
1686 where: (b: any) => b("state", "=", String(input.state)),
1687 }),
1688 );
1689 if (!sessionRow) {2056 if (!sessionRow) {
1690 return yield* new OAuthSessionNotFoundError({ state: input.state });2057 return yield* new OAuthSessionNotFoundError({ state: input.state });
1691 }2058 }
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 }
1692 const session = {2069 const session = {
1693 owner: String(sessionRow.owner) as Owner,2070 owner: String(sessionRow.owner) as Owner,
1694 clientSlug: OAuthClientSlug.make(String(sessionRow.client_slug)),2071 clientSlug: OAuthClientSlug.make(String(sessionRow.client_slug)),
@@ -1936,9 +2313,19 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 241 unmodified lines
1936 2313
1937 /** Mint a connection from an enterprise-managed grant. Distinct from2314 /** Mint a connection from an enterprise-managed grant. Distinct from
1938 * `mintFromToken` because the material persisted is different: there is no2315 * `mintFromToken` because the material persisted is different: there is no
1939 * refresh token (draft §4.4.3), and the identity assertion takes the refresh2316 * refresh token (draft §4.4.3), and the SUBJECT TOKEN takes the refresh slot
1940 * slot — it is exactly the credential that lets renewal run without the2317 * — it is exactly the credential that lets renewal run without the user,
1941 * user, which is what that slot means. */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. */
1942 const mintEnterpriseManagedConnection = (2329 const mintEnterpriseManagedConnection = (
1943 target: {2330 target: {
1944 readonly owner: Owner;2331 readonly owner: Owner;
@@ -1950,30 +2337,45 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 5 unmodified lines
1950 client: LoadedOAuthClient,2337 client: LoadedOAuthClient,
1951 clientOwner: Owner,2338 clientOwner: Owner,
1952 grant: EnterpriseManagedGrant,2339 grant: EnterpriseManagedGrant,
1953 /** The connect request's enterprise inputs with the assertion type already2340 /** Which IdP registration minted the ID-JAG, as named on the connect. */
1954 * resolved — the persisted state records what was actually presented, so2341 idp: Pick<EnterpriseManagedStartInput, "idpClient" | "idpClientOwner">,
1955 * it must not re-derive a default the chain might have differed on. */2342 /** The subject actually presented, with its type and its custody already
1956 enterprise: EnterpriseManagedStartInput & { readonly subjectTokenType: SubjectTokenType },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 },
1957 /** The Resource Authorization Server's issuer identifier, as discovered. */2350 /** The Resource Authorization Server's issuer identifier, as discovered. */
1958 audience: string,2351 audience: string,
1959 ): Effect.Effect<Connection, StorageFailure> =>2352 ): Effect.Effect<Connection, StorageFailure> =>
1960 Effect.gen(function* () {2353 Effect.gen(function* () {
1961 const provider = deps.defaultWritableProvider();2354 const { provider, set } = yield* requireWritableProvider("store the OAuth access token");
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 }
1969 const itemId = accessItemId(target.owner, target.integration, target.name);2355 const itemId = accessItemId(target.owner, target.integration, target.name);
1970 yield* provider.set(ProviderItemId.make(itemId), grant.token.access_token);2356 yield* set(ProviderItemId.make(itemId), grant.token.access_token);
1971 const subjectTokenItemId = refreshItemIdFor(itemId);2357
1972 yield* provider.set(ProviderItemId.make(subjectTokenItemId), enterprise.subjectToken);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 }
1973 2374
1974 yield* Effect.annotateCurrentSpan({2375 yield* Effect.annotateCurrentSpan({
1975 "executor.oauth.has_advertised_expiry": typeof grant.token.expires_in === "number",2376 "executor.oauth.has_advertised_expiry": typeof grant.token.expires_in === "number",
1976 "executor.oauth.enterprise_managed": true,2377 "executor.oauth.enterprise_managed": true,
2378 "executor.oauth.enterprise_subject_source": subject.source,
1977 });2379 });
1978 return yield* deps.mintOAuthConnection({2380 return yield* deps.mintOAuthConnection({
1979 owner: target.owner,2381 owner: target.owner,
@@ -1990,14 +2392,42 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 10 unmodified lines
1990 expiresAt: expiresAtFrom(grant.token),2392 expiresAt: expiresAtFrom(grant.token),
1991 oauthScope: grant.scope,2393 oauthScope: grant.scope,
1992 enterpriseManaged: {2394 enterpriseManaged: {
1993 idpClient: enterprise.idpClient,2395 idpClient: idp.idpClient,
1994 idpClientOwner: enterprise.idpClientOwner,2396 idpClientOwner: idp.idpClientOwner,
1995 audience,2397 audience,
1996 subjectTokenType: enterprise.subjectTokenType,2398 subjectTokenType: subject.tokenType,
2399 subjectSource: subject.source,
1997 },2400 },
1998 });2401 });
1999 });2402 });
2000 2403
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
2001 const deleteSession = (state: OAuthState): Effect.Effect<void, StorageFailure> =>2431 const deleteSession = (state: OAuthState): Effect.Effect<void, StorageFailure> =>
2002 deps.fuma2432 deps.fuma
2003 .use("oauth_session.delete", (db) =>2433 .use("oauth_session.delete", (db) =>
@@ -2069,7 +2499,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 65 unmodified lines
2069 listClients,2499 listClients,
2070 start,2500 start,
2071 complete,2501 complete,
2502 completeCallback,
2072 cancel,2503 cancel,
2073 probe,2504 probe,
2505 startWorkIdentityLink,
2506 completeWorkIdentityLink,
2507 workIdentityStatus,
2508 unlinkWorkIdentity,
2074 };2509 };
2075};2510};
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
22import { assert, describe, expect, it } from "@effect/vitest";
23import { Effect, Predicate } from "effect";
24
25import {
26 AuthTemplateSlug,
27 ConnectionName,
28 IntegrationSlug,
29 OAuthClientSlug,
30 ToolAddress,
31 ToolName,
32} from "./ids";
33import type { OAuthService } from "./oauth-client";
34import { definePlugin } from "./plugin";
35import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
36import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server";
37
38const INTEG = IntegrationSlug.make("acme");
39const TEMPLATE = AuthTemplateSlug.make("oauth");
40const IDP_CLIENT = OAuthClientSlug.make("enterprise-idp");
41const RESOURCE_CLIENT = OAuthClientSlug.make("mcp-server-app");
42const CONNECTION = ConnectionName.make("work");
43const SECOND_CONNECTION = ConnectionName.make("work2");
44const TOOL = ToolAddress.make("tools.acme.org.work.whoami");
45const SECOND_TOOL = ToolAddress.make("tools.acme.org.work2.whoami");
46
47const CLIENT_AT_IDP = "client-at-idp";
48const CLIENT_AT_RESOURCE = "client-at-resource";
49const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token" as const;
50const 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. */
55const ID_TOKEN_EXP_SECONDS = Math.floor(Date.now() / 1000) + 3600;
56const ACCOUNT_CLAIMS = {
57 sub: "00u-enterprise-1",
58 email: "alice@enterprise.test",
59 exp: ID_TOKEN_EXP_SECONDS,
60} as const;
61
62const 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. */
87const workspace = () =>
88 makeTestWorkspaceHarness({ plugins: [memoryCredentialsPlugin(), oauthPlugin] as const });
89
90interface EnterpriseServers {
91 readonly idp: OAuthTestServerShape;
92 readonly resource: OAuthTestServerShape;
93}
94
95const 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
124const 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
147const 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. */
157const 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. */
172const 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
183const 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
192const healthOf = (
193 connections: readonly { readonly name: ConnectionName; readonly lastHealth?: unknown }[],
194 name: ConnectionName,
195) => connections.find((entry) => String(entry.name) === String(name))?.lastHealth;
196
197describe("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
379describe("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
477describe("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
519describe("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
41import { Option, Schema } from "effect";
42
43import { OAuthClientSlug, OAuthState, Owner, ProviderItemId } from "./ids";
44import { 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. */
57export 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});
66export 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. */
74export 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. */
86export const WorkIdentityRevocationReasonSchema = Schema.Literals(["rejected"]).annotate({
87 identifier: "WorkIdentityRevocationReason",
88});
89export 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. */
97export 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});
128export interface WorkIdentityRecord extends Schema.Schema.Type<typeof WorkIdentityRecordSchema> {}
129
130const 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). */
135export const decodeWorkIdentityRecord = Schema.decodeUnknownOption(WorkIdentityRecordFromJson);
136
137/** Serialize a record for the credential provider. */
138export const encodeWorkIdentityRecord = Schema.encodeSync(WorkIdentityRecordFromJson);
139
140/** Whether this record can still be presented to the IdP. */
141export 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. */
147export 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
159const 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. */
181export 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});
209export 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. */
213export 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. */
257export const DEFAULT_WORK_IDENTITY_SCOPES: readonly string[] = ["openid", "offline_access"];
258
259export 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
269export 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. */
289export const WORK_IDENTITY_SESSION_SENTINEL = "work-identity";
290
291export 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" });
300export interface WorkIdentitySessionPayload extends Schema.Schema.Type<
301 typeof WorkIdentitySessionPayloadSchema
302> {}
303
304const 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. */
310export 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. */
322export 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. */
342export 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
6// connection:<owner>:<integration>:<name>:<variable>6// connection:<owner>:<integration>:<name>:<variable>
7// oauth:<owner>:<integration>:<name>[:refresh]7// oauth:<owner>:<integration>:<name>[:refresh]
8// oauth-client:<owner>:<slug>:secret8// oauth-client:<owner>:<slug>:secret
9// work-identity:<owner>:<idp-client-owner>:<idp-client>
9//10//
10// Credential providers file plugin-storage rows by THIS owner, not the acting11// Credential providers file plugin-storage rows by THIS owner, not the acting
11// caller's binding — an org connection whose OAuth consent completes in one12// 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
21 "connection",22 "connection",
22 "oauth",23 "oauth",
23 "oauth-client",24 "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",
24]);30]);
25 31
26/** The owner a logical item id embeds, or null for ids that carry none32/** 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
173 OAuthProbeError,173 OAuthProbeError,
174 OAuthRegisterDynamicError,174 OAuthRegisterDynamicError,
175 OAuthSessionNotFoundError,175 OAuthSessionNotFoundError,
176 type OAuthCallbackCompletion,
177 type WorkIdentityLinkStart,
176} from "./oauth-client";178} from "./oauth-client";
177 179
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.
182export {
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
178// Wire-level HTTP error schema for plugin HttpApiGroup definitions.195// Wire-level HTTP error schema for plugin HttpApiGroup definitions.
179export { InternalError } from "./api-errors";196export { InternalError } from "./api-errors";
180 197
packages/core/sdk/src/testing/oauth-test-server.ts+46−11
@@ -64,6 +64,12 @@ export interface OAuthTestServerOptions { · 63 unmodified lines
64 readonly scopes?: readonly string[];64 readonly scopes?: readonly string[];
65 readonly omitTokenResponseScopes?: readonly string[];65 readonly omitTokenResponseScopes?: readonly string[];
66 readonly supportRefresh?: boolean;66 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;
67 readonly tokenExpiresInSeconds?: number;73 readonly tokenExpiresInSeconds?: number;
68 readonly invalidRefreshTokenDescription?: string;74 readonly invalidRefreshTokenDescription?: string;
69 /** RFC 6749 error code returned when a refresh-token grant is rejected.75 /** RFC 6749 error code returned when a refresh-token grant is rejected.
@@ -152,6 +158,15 @@ export interface OAuthTestServerShape { · 82 unmodified lines
152 * fixture rejects the exchange afterwards exactly as it would for an expired158 * fixture rejects the exchange afterwards exactly as it would for an expired
153 * or revoked assertion. */159 * or revoked assertion. */
154 readonly revokeAccessToken: (token: string) => Effect.Effect<void>;160 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>;
155 /** Start (or stop) refusing every RFC 8693 exchange with this §5.2 error.170 /** Start (or stop) refusing every RFC 8693 exchange with this §5.2 error.
156 * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives171 * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives
157 * the case that only exists over time — an administrator withdrawing access172 * the case that only exists over time — an administrator withdrawing access
@@ -528,6 +543,7 @@ export const serveOAuthTestServer = ( · 370 unmodified lines
528 ...(options.users ?? {}),543 ...(options.users ?? {}),
529 };544 };
530 const supportRefresh = options.supportRefresh ?? true;545 const supportRefresh = options.supportRefresh ?? true;
546 const issueRefreshToken = options.issueRefreshToken ?? true;
531 const tokenExpiresInSeconds = options.tokenExpiresInSeconds ?? 3600;547 const tokenExpiresInSeconds = options.tokenExpiresInSeconds ?? 3600;
532 const invalidRefreshTokenDescription =548 const invalidRefreshTokenDescription =
533 options.invalidRefreshTokenDescription ?? "Unknown refresh token";549 options.invalidRefreshTokenDescription ?? "Unknown refresh token";
@@ -791,20 +807,22 @@ export const serveOAuthTestServer = ( · 257 unmodified lines
791 }807 }
792 authorizationCodes.delete(code);808 authorizationCodes.delete(code);
793 const accessToken = `at_${randomUUID()}`;809 const accessToken = `at_${randomUUID()}`;
794 const refreshToken = `rt_${randomUUID()}`;810 const refreshToken = issueRefreshToken ? `rt_${randomUUID()}` : null;
795 yield* Ref.update(issuedAccessTokens, (tokens) => new Set([...tokens, accessToken]));811 yield* Ref.update(issuedAccessTokens, (tokens) => new Set([...tokens, accessToken]));
796 refreshTokens.set(refreshToken, {812 if (refreshToken !== null) {
797 clientId,813 refreshTokens.set(refreshToken, {
798 username: record.username,814 clientId,
799 scope: record.scope,815 username: record.username,
800 resource: record.resource,816 scope: record.scope,
801 });817 resource: record.resource,
818 });
819 }
802 const scope = tokenResponseScope(record.scope);820 const scope = tokenResponseScope(record.scope);
803 return jsonResponse(821 return jsonResponse(
804 200,822 200,
805 {823 {
806 access_token: accessToken,824 access_token: accessToken,
807 refresh_token: refreshToken,825 ...(refreshToken === null ? {} : { refresh_token: refreshToken }),
808 token_type: "Bearer",826 token_type: "Bearer",
809 expires_in: tokenExpiresInSeconds,827 expires_in: tokenExpiresInSeconds,
810 ...(scope ? { scope } : {}),828 ...(scope ? { scope } : {}),
@@ -907,9 +925,18 @@ export const serveOAuthTestServer = ( · 96 unmodified lines
907 if (denial) {925 if (denial) {
908 return oauthError(400, denial.error, denial.errorDescription);926 return oauthError(400, denial.error, denial.errorDescription);
909 }927 }
910 const subjectAccepted = yield* Ref.get(issuedAccessTokens).pipe(928 // draft §4.5: a REFRESH token is a first-class subject, and it is
911 Effect.map((tokens) => tokens.has(subjectToken)),929 // VALIDATED, NOT CONSUMED — the exchange is not a refresh-token
912 );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 );
913 if (!subjectAccepted) {940 if (!subjectAccepted) {
914 return oauthError(941 return oauthError(
915 400,942 400,
@@ -1069,6 +1096,14 @@ export const serveOAuthTestServer = ( · 153 unmodified lines
1069 next.delete(token);1096 next.delete(token);
1070 return next;1097 return next;
1071 }),1098 }),
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 }),
1072 setTokenExchangeDenial: (denial) => Ref.set(tokenExchangeDenial, denial),1107 setTokenExchangeDenial: (denial) => Ref.set(tokenExchangeDenial, denial),
1073 acceptsAuthorizationHeader: (authorization) => {1108 acceptsAuthorizationHeader: (authorization) => {
1074 const token = authorization?.replace(/^Bearer\s+/i, "");1109 const token = authorization?.replace(/^Bearer\s+/i, "");