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.
Unified · Split
| @@ -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. | ||
| 325 | export { | ||
| 326 | DEFAULT_WORK_IDENTITY_SCOPES, | ||
| 327 | WORK_IDENTITY_SESSION_SENTINEL, | ||
| 328 | WorkIdentityLinkError, | ||
| 329 | WorkIdentityRefSchema, | ||
| 330 | WorkIdentityRevocationReasonSchema, | ||
| 331 | WorkIdentityStatusSchema, | ||
| 332 | workIdentityItemId, | ||
| 333 | workIdentityStatusOf, | ||
| 334 | type CompleteWorkIdentityLinkInput, | ||
| 335 | type StartWorkIdentityLinkInput, | ||
| 336 | type WorkIdentityRecord, | ||
| 337 | type WorkIdentityRef, | ||
| 338 | type WorkIdentityRevocationReason, | ||
| 339 | type WorkIdentityStatus, | ||
| 340 | } from "./oauth-work-identity"; | ||
| 341 | |||
| 320 | // The enterprise-managed rollout PORT (not its implementation): hosts that | 342 | // The enterprise-managed rollout PORT (not its implementation): hosts that |
| 321 | // operate a feature-flag service implement this and hand it to | 343 | // 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. |
| @@ -12,6 +12,13 @@ import { · 11 unmodified lines | |||
| 12 | OAuthState, | 12 | OAuthState, |
| 13 | Owner, | 13 | Owner, |
| 14 | } from "./ids"; | 14 | } from "./ids"; |
| 15 | import 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-jag | 24 | * 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 identity | 287 | * |
| 281 | * assertion comes from" is a host concern: a desktop app holds its own SSO | 288 | * 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's | 301 | * 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 token | 307 | /** The identity assertion from single sign-on with the IdP. OMIT to use the |
| 291 | * by default). Persisted through the credential provider so token renewal | 308 | * 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 | }); |
| 301 | export type EnterpriseManagedStartInput = typeof EnterpriseManagedStartInputSchema.Type; | 321 | export 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. */ | ||
| 328 | export type OAuthCallbackCompletion = | ||
| 329 | | { readonly kind: "connection"; readonly connection: Connection } | ||
| 330 | | { readonly kind: "work-identity"; readonly workIdentity: WorkIdentityStatus }; | ||
| 331 | |||
| 303 | export interface OAuthCompleteInput { | 332 | export 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 so | 406 | * 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 UserActionableError | 417 | 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. */ | ||
| 562 | export interface WorkIdentityLinkStart { | ||
| 563 | readonly authorizationUrl: string; | ||
| 564 | readonly state: OAuthState; | ||
| 474 | } | 565 | } |
| @@ -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: |
| @@ -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). */ | ||
| 742 | export const idTokenAccountFacts = ( | ||
| 743 | idToken: string | undefined, | ||
| 744 | ): { | ||
| 745 | readonly subject: string | null; | ||
| 746 | readonly label: string | null; | ||
| 747 | readonly expiresAt: number | null; | ||
| 748 | } => { | ||
| 749 | const claims = idToken === undefined ? null : decodeJwtPayload(idToken); | ||
| 750 | if (claims === null) return { subject: null, label: null, expiresAt: null }; | ||
| 751 | const exp = claims.exp; | ||
| 752 | return { | ||
| 753 | subject: stringClaim(claims, "sub") ?? null, | ||
| 754 | label: idTokenIdentityLabel(idToken) ?? null, | ||
| 755 | expiresAt: typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null, | ||
| 756 | }; | ||
| 757 | }; | ||
| 758 | |||
| 723 | type StrippedTokenResponse = { | 759 | type 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 | ||
| 728 | const NestedAuthedUserScope = Schema.Struct({ | 765 | const 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.idTokenIdentityLabel | 858 | 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 | // --------------------------------------------------------------------------- |
| @@ -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"; |
| 80 | import { | ||
| 81 | DEFAULT_WORK_IDENTITY_SCOPES, | ||
| 82 | WORK_IDENTITY_SESSION_SENTINEL, | ||
| 83 | WorkIdentityLinkError, | ||
| 84 | decodeWorkIdentityRecord, | ||
| 85 | encodeWorkIdentityRecord, | ||
| 86 | isWorkIdentityUsable, | ||
| 87 | workIdentityCustodyType, | ||
| 88 | workIdentityItemId, | ||
| 89 | workIdentitySessionPayloadFrom, | ||
| 90 | workIdentityStatusOf, | ||
| 91 | type CompleteWorkIdentityLinkInput, | ||
| 92 | type StartWorkIdentityLinkInput, | ||
| 93 | type WorkIdentityRecord, | ||
| 94 | type WorkIdentityRef, | ||
| 95 | type WorkIdentityStatus, | ||
| 96 | } from "./oauth-work-identity"; | ||
| 78 | import { | 97 | import { |
| 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 | ||
| 344 | const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); | 364 | const decodeJsonPayload = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); |
| 345 | 365 | ||
| 346 | /** Extract the persisted `requestedScopes` from an `oauth_session.payload`. The | 366 | /** 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 JSON | 367 | * 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, so | 369 | type 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. */ | ||
| 375 | const sessionPayload = (payload: unknown): unknown => | ||
| 376 | typeof payload === "string" | ||
| 377 | ? decodeJsonPayload(payload).pipe(Option.getOrElse(() => payload)) | ||
| 378 | : payload; | ||
| 379 | |||
| 380 | /** Extract the persisted `requestedScopes` from an `oauth_session.payload`. | ||
| 381 | * Returns `null` for legacy sessions written before `requestedScopes` was | ||
| 382 | * persisted, so `complete` can fall back to the client's scopes. */ | ||
| 351 | const requestedScopesFromPayload = (payload: unknown): readonly string[] | null => { | 383 | const 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. */ |
| 364 | const clientOwnerFromPayload = (payload: unknown): Owner | null => { | 393 | const 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 sends | 1887 | // Resolve WHICH subject token is presented — and whose it is — ONCE: |
| 1515 | // it and the connection persists it, and those two must not be able | 1888 | // 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 from | 2314 | /** Mint a connection from an enterprise-managed grant. Distinct from |
| 1938 | * `mintFromToken` because the material persisted is different: there is no | 2315 | * `mintFromToken` because the material persisted is different: there is no |
| 1939 | * refresh token (draft §4.4.3), and the identity assertion takes the refresh | 2316 | * 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 the | 2317 | * — 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 already | 2340 | /** Which IdP registration minted the ID-JAG, as named on the connect. */ |
| 1954 | * resolved — the persisted state records what was actually presented, so | 2341 | 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.fuma | 2432 | 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 | }; |
| @@ -0,0 +1,641 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Work identity — acquiring, holding and losing the enterprise assertion that | ||
| 3 | // enterprise-managed authorization consumes. | ||
| 4 | // | ||
| 5 | // `oauth-ema-lifecycle.test.ts` covers the caller-supplied-assertion world: the | ||
| 6 | // connect request carries the assertion, the connection keeps a private copy, | ||
| 7 | // and when that copy dies the CONNECTION is dead. This file covers the world the | ||
| 8 | // console lives in, where the product acquires the assertion itself, and where | ||
| 9 | // the two claims that follow from that are the whole point: | ||
| 10 | // | ||
| 11 | // 1. custody is the IdP REFRESH token (draft §4.5), so renewal outlives the | ||
| 12 | // ID token that would otherwise kill every managed connection within the | ||
| 13 | // hour, and | ||
| 14 | // 2. the identity is SHARED by every connection made from it, so its death is | ||
| 15 | // one fact with one remedy — re-link once — rather than N dead connections | ||
| 16 | // needing N reconnects. | ||
| 17 | // | ||
| 18 | // Both are asserted through the executor's own surfaces and the identity | ||
| 19 | // provider's request ledger, never by reading private state. | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | import { assert, describe, expect, it } from "@effect/vitest"; | ||
| 23 | import { Effect, Predicate } from "effect"; | ||
| 24 | |||
| 25 | import { | ||
| 26 | AuthTemplateSlug, | ||
| 27 | ConnectionName, | ||
| 28 | IntegrationSlug, | ||
| 29 | OAuthClientSlug, | ||
| 30 | ToolAddress, | ||
| 31 | ToolName, | ||
| 32 | } from "./ids"; | ||
| 33 | import type { OAuthService } from "./oauth-client"; | ||
| 34 | import { definePlugin } from "./plugin"; | ||
| 35 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | ||
| 36 | import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; | ||
| 37 | |||
| 38 | const INTEG = IntegrationSlug.make("acme"); | ||
| 39 | const TEMPLATE = AuthTemplateSlug.make("oauth"); | ||
| 40 | const IDP_CLIENT = OAuthClientSlug.make("enterprise-idp"); | ||
| 41 | const RESOURCE_CLIENT = OAuthClientSlug.make("mcp-server-app"); | ||
| 42 | const CONNECTION = ConnectionName.make("work"); | ||
| 43 | const SECOND_CONNECTION = ConnectionName.make("work2"); | ||
| 44 | const TOOL = ToolAddress.make("tools.acme.org.work.whoami"); | ||
| 45 | const SECOND_TOOL = ToolAddress.make("tools.acme.org.work2.whoami"); | ||
| 46 | |||
| 47 | const CLIENT_AT_IDP = "client-at-idp"; | ||
| 48 | const CLIENT_AT_RESOURCE = "client-at-resource"; | ||
| 49 | const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token" as const; | ||
| 50 | const ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" as const; | ||
| 51 | |||
| 52 | /** The enterprise account the IdP fixture signs the user in as. `exp` is far | ||
| 53 | * enough out that nothing here depends on wall-clock drift; the point of the | ||
| 54 | * ID-token custody tier is the RECORDED deadline, not reaching it. */ | ||
| 55 | const ID_TOKEN_EXP_SECONDS = Math.floor(Date.now() / 1000) + 3600; | ||
| 56 | const ACCOUNT_CLAIMS = { | ||
| 57 | sub: "00u-enterprise-1", | ||
| 58 | email: "alice@enterprise.test", | ||
| 59 | exp: ID_TOKEN_EXP_SECONDS, | ||
| 60 | } as const; | ||
| 61 | |||
| 62 | const oauthPlugin = definePlugin(() => ({ | ||
| 63 | id: "acme" as const, | ||
| 64 | storage: () => ({}), | ||
| 65 | resolveTools: () => | ||
| 66 | Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }), | ||
| 67 | describeAuthMethods: () => [ | ||
| 68 | { | ||
| 69 | id: "oauth", | ||
| 70 | label: "OAuth2", | ||
| 71 | kind: "oauth" as const, | ||
| 72 | template: String(TEMPLATE), | ||
| 73 | oauth: { scopes: ["mcp.read"] }, | ||
| 74 | }, | ||
| 75 | ], | ||
| 76 | invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), | ||
| 77 | extension: (ctx) => ({ | ||
| 78 | seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), | ||
| 79 | }), | ||
| 80 | }))(); | ||
| 81 | |||
| 82 | /** A workspace with its OWN credential store. The store must not be shared: a | ||
| 83 | * work identity is filed under an id derived only from (owner, IdP app), so a | ||
| 84 | * store shared across tests would let one test's held identity be resolved by | ||
| 85 | * the next — which is exactly the sharing the feature relies on in production | ||
| 86 | * and exactly the wrong thing between test cases. */ | ||
| 87 | const workspace = () => | ||
| 88 | makeTestWorkspaceHarness({ plugins: [memoryCredentialsPlugin(), oauthPlugin] as const }); | ||
| 89 | |||
| 90 | interface EnterpriseServers { | ||
| 91 | readonly idp: OAuthTestServerShape; | ||
| 92 | readonly resource: OAuthTestServerShape; | ||
| 93 | } | ||
| 94 | |||
| 95 | const enterpriseServers = ( | ||
| 96 | options: { | ||
| 97 | /** Stand in for an identity provider that hands back no refresh token, which | ||
| 98 | * forces the link onto ID-token custody. */ | ||
| 99 | readonly issueRefreshToken?: boolean; | ||
| 100 | readonly resourceTokenExpiresInSeconds?: number; | ||
| 101 | } = {}, | ||
| 102 | ) => | ||
| 103 | Effect.gen(function* () { | ||
| 104 | const idp = yield* serveOAuthTestServer({ | ||
| 105 | clients: { [CLIENT_AT_IDP]: null }, | ||
| 106 | scopes: ["mcp.read"], | ||
| 107 | idTokenClaims: ACCOUNT_CLAIMS, | ||
| 108 | ...(options.issueRefreshToken === undefined | ||
| 109 | ? {} | ||
| 110 | : { issueRefreshToken: options.issueRefreshToken }), | ||
| 111 | enterpriseIdp: { resourceClientIds: { [CLIENT_AT_IDP]: CLIENT_AT_RESOURCE } }, | ||
| 112 | }); | ||
| 113 | const resource = yield* serveOAuthTestServer({ | ||
| 114 | clients: { [CLIENT_AT_RESOURCE]: null }, | ||
| 115 | scopes: ["mcp.read"], | ||
| 116 | ...(options.resourceTokenExpiresInSeconds === undefined | ||
| 117 | ? {} | ||
| 118 | : { tokenExpiresInSeconds: options.resourceTokenExpiresInSeconds }), | ||
| 119 | enterpriseResourceServer: { trustedIdpIssuer: idp.issuerUrl }, | ||
| 120 | }); | ||
| 121 | return { idp, resource } satisfies EnterpriseServers; | ||
| 122 | }); | ||
| 123 | |||
| 124 | const registerClients = (createClient: OAuthService["createClient"], servers: EnterpriseServers) => | ||
| 125 | Effect.gen(function* () { | ||
| 126 | yield* createClient({ | ||
| 127 | owner: "org", | ||
| 128 | slug: IDP_CLIENT, | ||
| 129 | authorizationUrl: servers.idp.authorizationEndpoint, | ||
| 130 | tokenUrl: servers.idp.tokenEndpoint, | ||
| 131 | grant: "authorization_code", | ||
| 132 | clientId: CLIENT_AT_IDP, | ||
| 133 | clientSecret: "", | ||
| 134 | }); | ||
| 135 | yield* createClient({ | ||
| 136 | owner: "org", | ||
| 137 | slug: RESOURCE_CLIENT, | ||
| 138 | authorizationUrl: servers.resource.authorizationEndpoint, | ||
| 139 | tokenUrl: servers.resource.tokenEndpoint, | ||
| 140 | grant: "id_jag", | ||
| 141 | clientId: CLIENT_AT_RESOURCE, | ||
| 142 | clientSecret: "", | ||
| 143 | resource: servers.resource.mcpResourceUrl, | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | |||
| 147 | const WORK_IDENTITY = { | ||
| 148 | owner: "org", | ||
| 149 | idpClient: IDP_CLIENT, | ||
| 150 | idpClientOwner: "org", | ||
| 151 | } as const; | ||
| 152 | |||
| 153 | /** Drive the whole link the way a browser does: ask for the authorization URL, | ||
| 154 | * sign in at the identity provider, hand the code back. No private state is | ||
| 155 | * touched — everything below observes the result through `workIdentityStatus` | ||
| 156 | * and through what the identity provider was later asked. */ | ||
| 157 | const linkWorkIdentity = (oauth: OAuthService, servers: EnterpriseServers) => | ||
| 158 | Effect.gen(function* () { | ||
| 159 | const started = yield* oauth.startWorkIdentityLink(WORK_IDENTITY); | ||
| 160 | const callback = yield* servers.idp.completeAuthorizationCodeFlow({ | ||
| 161 | authorizationUrl: started.authorizationUrl, | ||
| 162 | }); | ||
| 163 | return yield* oauth.completeWorkIdentityLink({ | ||
| 164 | state: started.state, | ||
| 165 | code: callback.code, | ||
| 166 | }); | ||
| 167 | }); | ||
| 168 | |||
| 169 | /** A connect that names the identity provider but carries NO assertion — the | ||
| 170 | * console's request. Everything about which subject is presented, and who owns | ||
| 171 | * it, is resolved server-side. */ | ||
| 172 | const connectWithHeldIdentity = (name: ConnectionName = CONNECTION) => | ||
| 173 | ({ | ||
| 174 | owner: "org", | ||
| 175 | client: RESOURCE_CLIENT, | ||
| 176 | clientOwner: "org", | ||
| 177 | name, | ||
| 178 | integration: INTEG, | ||
| 179 | template: TEMPLATE, | ||
| 180 | enterprise: { idpClient: IDP_CLIENT, idpClientOwner: "org" }, | ||
| 181 | }) as const; | ||
| 182 | |||
| 183 | const tokenExchanges = (servers: EnterpriseServers) => | ||
| 184 | servers.idp.requests.pipe( | ||
| 185 | Effect.map((entries) => | ||
| 186 | entries | ||
| 187 | .filter((entry) => entry.path === "/token" && entry.body.includes("token-exchange")) | ||
| 188 | .map((entry) => new URLSearchParams(entry.body)), | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | |||
| 192 | const healthOf = ( | ||
| 193 | connections: readonly { readonly name: ConnectionName; readonly lastHealth?: unknown }[], | ||
| 194 | name: ConnectionName, | ||
| 195 | ) => connections.find((entry) => String(entry.name) === String(name))?.lastHealth; | ||
| 196 | |||
| 197 | describe("work identity — linking", () => { | ||
| 198 | it.effect("takes custody of the identity provider's refresh token, not its ID token", () => | ||
| 199 | Effect.scoped( | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const servers = yield* enterpriseServers(); | ||
| 202 | const { executor } = yield* workspace(); | ||
| 203 | yield* executor.acme.seed(); | ||
| 204 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 205 | |||
| 206 | expect( | ||
| 207 | (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status, | ||
| 208 | "nothing is held before the user links", | ||
| 209 | ).toBe("unlinked"); | ||
| 210 | |||
| 211 | const linked = yield* linkWorkIdentity(executor.oauth, servers); | ||
| 212 | |||
| 213 | assert(linked.status === "linked"); | ||
| 214 | expect( | ||
| 215 | linked.subjectTokenType, | ||
| 216 | "draft §4.5: the durable subject is the refresh token — an ID token in custody would strand every managed connection at its first expiry", | ||
| 217 | ).toBe(REFRESH_TOKEN_TYPE); | ||
| 218 | expect( | ||
| 219 | linked.expiresAt, | ||
| 220 | "a refresh token has no client-visible expiry, and claiming one would be a deadline we invented", | ||
| 221 | ).toBeNull(); | ||
| 222 | expect(linked.subject, "the account is named from the ID token's claims").toBe( | ||
| 223 | ACCOUNT_CLAIMS.sub, | ||
| 224 | ); | ||
| 225 | expect(linked.label).toBe(ACCOUNT_CLAIMS.email); | ||
| 226 | expect(linked.idpClient).toBe(IDP_CLIENT); | ||
| 227 | }), | ||
| 228 | ), | ||
| 229 | ); | ||
| 230 | |||
| 231 | it.effect("requests openid and offline_access so both of those facts are obtainable", () => | ||
| 232 | Effect.scoped( | ||
| 233 | Effect.gen(function* () { | ||
| 234 | const servers = yield* enterpriseServers(); | ||
| 235 | const { executor } = yield* workspace(); | ||
| 236 | yield* executor.acme.seed(); | ||
| 237 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 238 | |||
| 239 | const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY); | ||
| 240 | |||
| 241 | const scope = new URL(started.authorizationUrl).searchParams.get("scope"); | ||
| 242 | expect( | ||
| 243 | scope?.split(" ").sort(), | ||
| 244 | "`openid` is where the account claims come from and `offline_access` is where the refresh token comes from; dropping either quietly costs the link its identity or its durability", | ||
| 245 | ).toEqual(["offline_access", "openid"]); | ||
| 246 | expect( | ||
| 247 | new URL(started.authorizationUrl).searchParams.get("code_challenge_method"), | ||
| 248 | "the link runs the same PKCE authorization-code flow as every other browser flow here", | ||
| 249 | ).toBe("S256"); | ||
| 250 | }), | ||
| 251 | ), | ||
| 252 | ); | ||
| 253 | |||
| 254 | it.effect( | ||
| 255 | "records ID-token custody, with its deadline, when the IdP issues no refresh token", | ||
| 256 | () => | ||
| 257 | Effect.scoped( | ||
| 258 | Effect.gen(function* () { | ||
| 259 | const servers = yield* enterpriseServers({ issueRefreshToken: false }); | ||
| 260 | const { executor } = yield* workspace(); | ||
| 261 | yield* executor.acme.seed(); | ||
| 262 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 263 | |||
| 264 | const linked = yield* linkWorkIdentity(executor.oauth, servers); | ||
| 265 | |||
| 266 | assert(linked.status === "linked"); | ||
| 267 | expect( | ||
| 268 | linked.subjectTokenType, | ||
| 269 | "custody of an ID token is a real degradation, so it is recorded as a different kind of custody rather than passed off as the durable one", | ||
| 270 | ).toBe(ID_TOKEN_TYPE); | ||
| 271 | expect( | ||
| 272 | linked.expiresAt, | ||
| 273 | "and it carries the deadline the product needs to warn before renewal starts failing", | ||
| 274 | ).toBe(ID_TOKEN_EXP_SECONDS * 1000); | ||
| 275 | }), | ||
| 276 | ), | ||
| 277 | ); | ||
| 278 | |||
| 279 | it.effect("forgets a linked identity on unlink", () => | ||
| 280 | Effect.scoped( | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const servers = yield* enterpriseServers(); | ||
| 283 | const { executor } = yield* workspace(); | ||
| 284 | yield* executor.acme.seed(); | ||
| 285 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 286 | yield* linkWorkIdentity(executor.oauth, servers); | ||
| 287 | |||
| 288 | yield* executor.oauth.unlinkWorkIdentity(WORK_IDENTITY); | ||
| 289 | expect((yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status).toBe("unlinked"); | ||
| 290 | |||
| 291 | yield* executor.oauth.unlinkWorkIdentity(WORK_IDENTITY); | ||
| 292 | expect( | ||
| 293 | (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status, | ||
| 294 | "unlinking twice is the same outcome as unlinking once", | ||
| 295 | ).toBe("unlinked"); | ||
| 296 | }), | ||
| 297 | ), | ||
| 298 | ); | ||
| 299 | |||
| 300 | it.effect("refuses to link through an app registered for another grant", () => | ||
| 301 | Effect.scoped( | ||
| 302 | Effect.gen(function* () { | ||
| 303 | const servers = yield* enterpriseServers(); | ||
| 304 | const { executor } = yield* workspace(); | ||
| 305 | yield* executor.acme.seed(); | ||
| 306 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 307 | |||
| 308 | const failure = yield* executor.oauth | ||
| 309 | .startWorkIdentityLink({ ...WORK_IDENTITY, idpClient: RESOURCE_CLIENT }) | ||
| 310 | .pipe(Effect.flip); | ||
| 311 | |||
| 312 | assert(Predicate.isTagged(failure, "WorkIdentityLinkError")); | ||
| 313 | expect( | ||
| 314 | failure.message, | ||
| 315 | "the id_jag app is the MCP server's registration; running an authorization-code flow through it would fail at the provider with an opaque error page", | ||
| 316 | ).toContain("id_jag"); | ||
| 317 | }), | ||
| 318 | ), | ||
| 319 | ); | ||
| 320 | |||
| 321 | it.effect("will not let a link's state be completed as a connection", () => | ||
| 322 | Effect.scoped( | ||
| 323 | Effect.gen(function* () { | ||
| 324 | const servers = yield* enterpriseServers(); | ||
| 325 | const { executor } = yield* workspace(); | ||
| 326 | yield* executor.acme.seed(); | ||
| 327 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 328 | |||
| 329 | const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY); | ||
| 330 | const callback = yield* servers.idp.completeAuthorizationCodeFlow({ | ||
| 331 | authorizationUrl: started.authorizationUrl, | ||
| 332 | }); | ||
| 333 | |||
| 334 | const failure = yield* executor.oauth | ||
| 335 | .complete({ state: started.state, code: callback.code }) | ||
| 336 | .pipe(Effect.flip); | ||
| 337 | |||
| 338 | assert( | ||
| 339 | Predicate.isTagged(failure, "OAuthCompleteError"), | ||
| 340 | "a link session carries sentinel integration/name/template columns; minting a connection out of them would produce a connection named after a placeholder", | ||
| 341 | ); | ||
| 342 | expect(failure.restartRequired).toBe(true); | ||
| 343 | expect( | ||
| 344 | (yield* executor.connections.list()).length, | ||
| 345 | "and nothing was minted on the way to refusing", | ||
| 346 | ).toBe(0); | ||
| 347 | }), | ||
| 348 | ), | ||
| 349 | ); | ||
| 350 | |||
| 351 | it.effect("routes a shared callback to the flow its session belongs to", () => | ||
| 352 | Effect.scoped( | ||
| 353 | Effect.gen(function* () { | ||
| 354 | const servers = yield* enterpriseServers(); | ||
| 355 | const { executor } = yield* workspace(); | ||
| 356 | yield* executor.acme.seed(); | ||
| 357 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 358 | |||
| 359 | const started = yield* executor.oauth.startWorkIdentityLink(WORK_IDENTITY); | ||
| 360 | const callback = yield* servers.idp.completeAuthorizationCodeFlow({ | ||
| 361 | authorizationUrl: started.authorizationUrl, | ||
| 362 | }); | ||
| 363 | |||
| 364 | const completion = yield* executor.oauth.completeCallback({ | ||
| 365 | state: started.state, | ||
| 366 | code: callback.code, | ||
| 367 | }); | ||
| 368 | |||
| 369 | assert( | ||
| 370 | completion.kind === "work-identity", | ||
| 371 | "both browser flows share one redirect URI, so the in-flight session — not the URL — is what says which one came back", | ||
| 372 | ); | ||
| 373 | expect(completion.workIdentity.status).toBe("linked"); | ||
| 374 | }), | ||
| 375 | ), | ||
| 376 | ); | ||
| 377 | }); | ||
| 378 | |||
| 379 | describe("work identity — enterprise-managed connect", () => { | ||
| 380 | it.effect("connects with no assertion on the request, presenting the held refresh token", () => | ||
| 381 | Effect.scoped( | ||
| 382 | Effect.gen(function* () { | ||
| 383 | const servers = yield* enterpriseServers(); | ||
| 384 | const { executor } = yield* workspace(); | ||
| 385 | yield* executor.acme.seed(); | ||
| 386 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 387 | yield* linkWorkIdentity(executor.oauth, servers); | ||
| 388 | |||
| 389 | const started = yield* executor.oauth.start(connectWithHeldIdentity()); | ||
| 390 | |||
| 391 | assert( | ||
| 392 | started.status === "connected", | ||
| 393 | "a linked user connects an enterprise-managed server with neither an assertion in hand nor a consent screen", | ||
| 394 | ); | ||
| 395 | const exchanges = yield* tokenExchanges(servers); | ||
| 396 | expect(exchanges.length).toBe(1); | ||
| 397 | expect( | ||
| 398 | exchanges[0]?.get("subject_token_type"), | ||
| 399 | "the exchange presents the durable subject the link took custody of", | ||
| 400 | ).toBe(REFRESH_TOKEN_TYPE); | ||
| 401 | |||
| 402 | const invoked = (yield* executor.execute(TOOL, {})) as { readonly token: string }; | ||
| 403 | expect( | ||
| 404 | yield* servers.resource.acceptsAccessToken(invoked.token), | ||
| 405 | "and the tool call rides the token the chain minted", | ||
| 406 | ).toBe(true); | ||
| 407 | }), | ||
| 408 | ), | ||
| 409 | ); | ||
| 410 | |||
| 411 | it.effect("tells an unlinked user to link, rather than failing as a credential problem", () => | ||
| 412 | Effect.scoped( | ||
| 413 | Effect.gen(function* () { | ||
| 414 | const servers = yield* enterpriseServers(); | ||
| 415 | const { executor } = yield* workspace(); | ||
| 416 | yield* executor.acme.seed(); | ||
| 417 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 418 | |||
| 419 | const failure = yield* executor.oauth.start(connectWithHeldIdentity()).pipe(Effect.flip); | ||
| 420 | |||
| 421 | assert(Predicate.isTagged(failure, "OAuthStartError")); | ||
| 422 | expect( | ||
| 423 | failure.workIdentityLinkRequired, | ||
| 424 | "a console decides from this field whether to offer the LINK; it cannot decide that from a sentence, and retrying or offering per-server consent are both the wrong move", | ||
| 425 | ).toBe(true); | ||
| 426 | expect( | ||
| 427 | failure.blockedByAdmin, | ||
| 428 | "nothing was asked of the identity provider, so no administrator refused anything", | ||
| 429 | ).toBeUndefined(); | ||
| 430 | expect( | ||
| 431 | (yield* tokenExchanges(servers)).length, | ||
| 432 | "and no assertion was spent finding that out", | ||
| 433 | ).toBe(0); | ||
| 434 | }), | ||
| 435 | ), | ||
| 436 | ); | ||
| 437 | |||
| 438 | it.effect("leaves the explicit-assertion path exactly as it was", () => | ||
| 439 | Effect.scoped( | ||
| 440 | Effect.gen(function* () { | ||
| 441 | const servers = yield* enterpriseServers(); | ||
| 442 | const { executor } = yield* workspace(); | ||
| 443 | yield* executor.acme.seed(); | ||
| 444 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 445 | |||
| 446 | // A headless caller holding its own assertion, with NOTHING linked. | ||
| 447 | const session = yield* servers.idp.completeAuthorizationCodeTokenFlow({ | ||
| 448 | clientId: CLIENT_AT_IDP, | ||
| 449 | clientSecret: "", | ||
| 450 | scopes: ["mcp.read"], | ||
| 451 | }); | ||
| 452 | const started = yield* executor.oauth.start({ | ||
| 453 | ...connectWithHeldIdentity(), | ||
| 454 | enterprise: { | ||
| 455 | idpClient: IDP_CLIENT, | ||
| 456 | idpClientOwner: "org", | ||
| 457 | subjectToken: session.accessToken, | ||
| 458 | subjectTokenType: "urn:ietf:params:oauth:token-type:access_token", | ||
| 459 | }, | ||
| 460 | }); | ||
| 461 | |||
| 462 | assert(started.status === "connected"); | ||
| 463 | const exchanges = yield* tokenExchanges(servers); | ||
| 464 | expect( | ||
| 465 | exchanges[0]?.get("subject_token_type"), | ||
| 466 | "what the caller passed is what is presented — resolution never overrides an explicit assertion", | ||
| 467 | ).toBe("urn:ietf:params:oauth:token-type:access_token"); | ||
| 468 | expect( | ||
| 469 | (yield* executor.oauth.workIdentityStatus(WORK_IDENTITY)).status, | ||
| 470 | "and a caller-supplied assertion is the caller's, so it never becomes the user's held identity", | ||
| 471 | ).toBe("unlinked"); | ||
| 472 | }), | ||
| 473 | ), | ||
| 474 | ); | ||
| 475 | }); | ||
| 476 | |||
| 477 | describe("work identity — rollout gate composability", () => { | ||
| 478 | it.effect("gates the connect exactly once and does not gate the link at all", () => | ||
| 479 | Effect.scoped( | ||
| 480 | Effect.gen(function* () { | ||
| 481 | const servers = yield* enterpriseServers(); | ||
| 482 | const consultations: string[] = []; | ||
| 483 | const { executor } = yield* makeTestWorkspaceHarness({ | ||
| 484 | plugins: [memoryCredentialsPlugin(), oauthPlugin] as const, | ||
| 485 | enterpriseManagedRollout: { | ||
| 486 | decide: (context) => | ||
| 487 | Effect.sync(() => { | ||
| 488 | consultations.push(String(context.integration)); | ||
| 489 | return { kind: "enabled" } as const; | ||
| 490 | }), | ||
| 491 | record: () => Effect.void, | ||
| 492 | }, | ||
| 493 | }); | ||
| 494 | yield* executor.acme.seed(); | ||
| 495 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 496 | |||
| 497 | yield* linkWorkIdentity(executor.oauth, servers); | ||
| 498 | expect( | ||
| 499 | consultations, | ||
| 500 | "linking an identity reaches no MCP server and spends no assertion, so gating it would be a second consultation buying nothing — and would make the rollout flag able to block a harmless, reusable action", | ||
| 501 | ).toEqual([]); | ||
| 502 | |||
| 503 | yield* executor.oauth.start(connectWithHeldIdentity()); | ||
| 504 | expect( | ||
| 505 | consultations, | ||
| 506 | "the connect is still gated, still exactly once, and still before anything leaves the process", | ||
| 507 | ).toEqual([String(INTEG)]); | ||
| 508 | |||
| 509 | yield* executor.execute(TOOL, {}); | ||
| 510 | expect( | ||
| 511 | consultations, | ||
| 512 | "and resolving credentials never re-consults it, so the flag can never strand a live connection", | ||
| 513 | ).toEqual([String(INTEG)]); | ||
| 514 | }), | ||
| 515 | ), | ||
| 516 | ); | ||
| 517 | }); | ||
| 518 | |||
| 519 | describe("work identity — lifecycle", () => { | ||
| 520 | it.effect("renews after the ID token that started the link would have died", () => | ||
| 521 | Effect.scoped( | ||
| 522 | Effect.gen(function* () { | ||
| 523 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); | ||
| 524 | const { executor } = yield* workspace(); | ||
| 525 | yield* executor.acme.seed(); | ||
| 526 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 527 | yield* linkWorkIdentity(executor.oauth, servers); | ||
| 528 | yield* executor.oauth.start(connectWithHeldIdentity()); | ||
| 529 | |||
| 530 | // Kill everything the SSO issued that expires: the access token, and | ||
| 531 | // with it the ID token minted beside it. Under the old design — an ID | ||
| 532 | // token in custody — this is precisely the state a connection reaches | ||
| 533 | // an hour after it was made, and every renewal from here fails. | ||
| 534 | for (const token of yield* servers.idp.issuedAccessTokens) { | ||
| 535 | yield* servers.idp.revokeAccessToken(token); | ||
| 536 | } | ||
| 537 | |||
| 538 | const first = (yield* executor.execute(TOOL, {})) as { readonly token: string }; | ||
| 539 | const second = (yield* executor.execute(TOOL, {})) as { readonly token: string }; | ||
| 540 | |||
| 541 | expect(second.token, "the expiring access token was replaced").not.toBe(first.token); | ||
| 542 | expect( | ||
| 543 | yield* servers.resource.acceptsAccessToken(second.token), | ||
| 544 | "renewal still reaches the identity provider and still comes back with a usable token, because custody is the refresh token", | ||
| 545 | ).toBe(true); | ||
| 546 | const exchanges = yield* tokenExchanges(servers); | ||
| 547 | expect( | ||
| 548 | exchanges.length, | ||
| 549 | "every renewal returns to the IdP, so enterprise policy is re-evaluated each time", | ||
| 550 | ).toBeGreaterThan(1); | ||
| 551 | expect( | ||
| 552 | exchanges.every((params) => params.get("subject_token_type") === REFRESH_TOKEN_TYPE), | ||
| 553 | "and every one of them presents the durable subject", | ||
| 554 | ).toBe(true); | ||
| 555 | }), | ||
| 556 | ), | ||
| 557 | ); | ||
| 558 | |||
| 559 | it.effect("turns a rejected identity into ONE re-link that revives every connection", () => | ||
| 560 | Effect.scoped( | ||
| 561 | Effect.gen(function* () { | ||
| 562 | const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 }); | ||
| 563 | const { executor } = yield* workspace(); | ||
| 564 | yield* executor.acme.seed(); | ||
| 565 | yield* registerClients(executor.oauth.createClient, servers); | ||
| 566 | const linked = yield* linkWorkIdentity(executor.oauth, servers); | ||
| 567 | assert(linked.status === "linked"); | ||
| 568 | yield* executor.oauth.start(connectWithHeldIdentity()); | ||
| 569 | yield* executor.oauth.start(connectWithHeldIdentity(SECOND_CONNECTION)); | ||
| 570 | yield* executor.execute(TOOL, {}); | ||
| 571 | yield* executor.execute(SECOND_TOOL, {}); | ||
| 572 | |||
| 573 | // The enterprise ends the user's sessions. Both connections renew from | ||
| 574 | // the SAME held identity, so both meet it. | ||
| 575 | expect( | ||
| 576 | yield* servers.idp.revokeRefreshTokensFor(CLIENT_AT_IDP), | ||
| 577 | "one link means one durable subject at the identity provider, however many connections were made from it", | ||
| 578 | ).toBe(1); | ||
| 579 | |||
| 580 | const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip); | ||
| 581 | assert( | ||
| 582 | Predicate.isTagged(failure, "CredentialResolutionError"), | ||
| 583 | "a dead identity is a credential verdict, not an execution fault", | ||
| 584 | ); | ||
| 585 | expect( | ||
| 586 | failure.workIdentityRelinkRequired, | ||
| 587 | "the remedy is a re-link, and a console that reads only `reauthRequired` would send the user to reconnect N connections instead", | ||
| 588 | ).toBe(true); | ||
| 589 | expect(failure.reauthRequired, "it is still definitive — no retry recovers it").toBe(true); | ||
| 590 | expect( | ||
| 591 | failure.blockedByAdmin, | ||
| 592 | "the subject died; the administrator withdrew nothing", | ||
| 593 | ).toBeUndefined(); | ||
| 594 | |||
| 595 | const status = yield* executor.oauth.workIdentityStatus(WORK_IDENTITY); | ||
| 596 | assert( | ||
| 597 | status.status === "needs_relink", | ||
| 598 | "the rejection is recorded on the IDENTITY — one fact, not one per connection", | ||
| 599 | ); | ||
| 600 | expect(status.revokedReason).toBe("rejected"); | ||
| 601 | expect( | ||
| 602 | status.label, | ||
| 603 | "and the account it names survives, so the user knows what to re-link", | ||
| 604 | ).toBe(ACCOUNT_CLAIMS.email); | ||
| 605 | |||
| 606 | const secondFailure = yield* executor.execute(SECOND_TOOL, {}).pipe(Effect.flip); | ||
| 607 | assert(Predicate.isTagged(secondFailure, "CredentialResolutionError")); | ||
| 608 | expect( | ||
| 609 | secondFailure.workIdentityRelinkRequired, | ||
| 610 | "the second connection reports the same one remedy", | ||
| 611 | ).toBe(true); | ||
| 612 | |||
| 613 | const stalled = yield* executor.connections.list(); | ||
| 614 | expect( | ||
| 615 | healthOf(stalled, CONNECTION), | ||
| 616 | "both connections show the problem without waiting for a probe", | ||
| 617 | ).toMatchObject({ status: "expired" }); | ||
| 618 | expect(healthOf(stalled, SECOND_CONNECTION)).toMatchObject({ status: "expired" }); | ||
| 619 | |||
| 620 | // ONE re-link. No reconnect, no `oauth.start`, nothing touching either | ||
| 621 | // connection — which is the claim: a dead work identity must not have | ||
| 622 | // stamped a reauth verdict onto connections that never lost anything. | ||
| 623 | const relinked = yield* linkWorkIdentity(executor.oauth, servers); | ||
| 624 | expect(relinked.status).toBe("linked"); | ||
| 625 | |||
| 626 | const revivedFirst = (yield* executor.execute(TOOL, {})) as { readonly token: string }; | ||
| 627 | const revivedSecond = (yield* executor.execute(SECOND_TOOL, {})) as { | ||
| 628 | readonly token: string; | ||
| 629 | }; | ||
| 630 | expect( | ||
| 631 | yield* servers.resource.acceptsAccessToken(revivedFirst.token), | ||
| 632 | "the first connection renews again after the single re-link", | ||
| 633 | ).toBe(true); | ||
| 634 | expect( | ||
| 635 | yield* servers.resource.acceptsAccessToken(revivedSecond.token), | ||
| 636 | "and so does the second — which is the whole difference between a dead identity and N dead connections", | ||
| 637 | ).toBe(true); | ||
| 638 | }), | ||
| 639 | ), | ||
| 640 | ); | ||
| 641 | }); | ||
| @@ -0,0 +1,353 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Work identity — the acquisition half of MCP Enterprise-Managed Authorization. | ||
| 3 | // | ||
| 4 | // The EMA connect path (`oauth.start` with `enterprise`) presents an identity | ||
| 5 | // assertion to the enterprise IdP and exchanges it for an ID-JAG. Nothing in the | ||
| 6 | // product ACQUIRED that assertion: the connect request simply required the | ||
| 7 | // caller to hand one over. A browser cannot obtain one — the IdP client's secret | ||
| 8 | // is server-side — so in practice there was no way to reach the profile from the | ||
| 9 | // console at all. | ||
| 10 | // | ||
| 11 | // A WORK IDENTITY closes that gap. A user links their enterprise identity ONCE | ||
| 12 | // per (owner, IdP client): executor runs an ordinary authorization-code flow | ||
| 13 | // against the org's registered IdP app, exchanges the code server-side with that | ||
| 14 | // app's credentials, and takes custody of the result. Every later | ||
| 15 | // enterprise-managed connect for that IdP client then resolves the held identity | ||
| 16 | // instead of asking the caller for one. | ||
| 17 | // | ||
| 18 | // WHAT IS HELD, AND WHY IT IS THE REFRESH TOKEN | ||
| 19 | // | ||
| 20 | // draft-ietf-oauth-identity-assertion-authz-grant §4.5 makes the refresh token a | ||
| 21 | // first-class `subject_token` for exactly this reason: an OIDC ID token lives | ||
| 22 | // about an hour, so an ID token in custody turns every renewal after that hour | ||
| 23 | // into "needs SSO" — the connection is dead a lunch break after it was made. A | ||
| 24 | // refresh token is the durable subject, and `SubjectTokenTypeSchema` already | ||
| 25 | // admits it. | ||
| 26 | // | ||
| 27 | // ID-token custody remains as an EXPLICIT, recorded degradation for an IdP that | ||
| 28 | // issues no refresh token (`tokenType` says which is held, and `expiresAt` | ||
| 29 | // carries the deadline). It is a different stored shape, not a silent fallback. | ||
| 30 | // | ||
| 31 | // CUSTODY LAYOUT | ||
| 32 | // | ||
| 33 | // The record lives in the default writable credential provider under a | ||
| 34 | // deterministic item id (see {@link workIdentityItemId}) — the same store the | ||
| 35 | // connection access/refresh material uses, with the same owner partitioning | ||
| 36 | // (`provider-item-owner.ts`). One record, N enterprise-managed connections: the | ||
| 37 | // connections POINT at this item rather than each keeping a private copy, which | ||
| 38 | // is what makes a single re-link recover all of them. | ||
| 39 | // --------------------------------------------------------------------------- | ||
| 40 | |||
| 41 | import { Option, Schema } from "effect"; | ||
| 42 | |||
| 43 | import { OAuthClientSlug, OAuthState, Owner, ProviderItemId } from "./ids"; | ||
| 44 | import { SubjectTokenTypeSchema, type SubjectTokenType } from "./oauth-client"; | ||
| 45 | |||
| 46 | // --------------------------------------------------------------------------- | ||
| 47 | // Addressing | ||
| 48 | // --------------------------------------------------------------------------- | ||
| 49 | |||
| 50 | /** Which held enterprise identity a request is about: the registered IdP app, | ||
| 51 | * and the owner partition the identity is filed under. | ||
| 52 | * | ||
| 53 | * `owner` is the owner the CONNECTIONS backed by this identity are made under, | ||
| 54 | * which is also the partition the credential provider files it in — a Personal | ||
| 55 | * connection resolves the user's own identity, a Workspace connection the | ||
| 56 | * org-shared one, exactly as an OAuth connection's tokens already work. */ | ||
| 57 | export const WorkIdentityRefSchema = Schema.Struct({ | ||
| 58 | owner: Owner, | ||
| 59 | idpClient: OAuthClientSlug, | ||
| 60 | idpClientOwner: Owner, | ||
| 61 | }).annotate({ | ||
| 62 | identifier: "WorkIdentityRef", | ||
| 63 | description: | ||
| 64 | "The (owner, enterprise identity provider app) pair a held work identity is keyed by.", | ||
| 65 | }); | ||
| 66 | export interface WorkIdentityRef extends Schema.Schema.Type<typeof WorkIdentityRefSchema> {} | ||
| 67 | |||
| 68 | /** The credential-provider item a work identity occupies. | ||
| 69 | * | ||
| 70 | * The `work-identity:<owner>:…` grammar is deliberate: `provider-item-owner.ts` | ||
| 71 | * reads the second segment to decide which partition a provider files the value | ||
| 72 | * under, so this id must keep that shape or an org identity would be written | ||
| 73 | * into the acting member's private partition. */ | ||
| 74 | export const workIdentityItemId = (ref: WorkIdentityRef): ProviderItemId => | ||
| 75 | ProviderItemId.make(`work-identity:${ref.owner}:${ref.idpClientOwner}:${String(ref.idpClient)}`); | ||
| 76 | |||
| 77 | // --------------------------------------------------------------------------- | ||
| 78 | // The persisted record | ||
| 79 | // --------------------------------------------------------------------------- | ||
| 80 | |||
| 81 | /** Why a held identity stopped working. `rejected` is the only value the | ||
| 82 | * renewal path can produce: the IdP refused the stored subject at the token | ||
| 83 | * exchange (RFC 6749 `invalid_grant`), which is definitive — the user must link | ||
| 84 | * again. Kept as a closed set so the product can say something specific rather | ||
| 85 | * than rendering a stored sentence. */ | ||
| 86 | export const WorkIdentityRevocationReasonSchema = Schema.Literals(["rejected"]).annotate({ | ||
| 87 | identifier: "WorkIdentityRevocationReason", | ||
| 88 | }); | ||
| 89 | export type WorkIdentityRevocationReason = typeof WorkIdentityRevocationReasonSchema.Type; | ||
| 90 | |||
| 91 | /** Everything custody of a work identity means, as stored in the credential | ||
| 92 | * provider. The whole record lives in the secret store — not just the token — | ||
| 93 | * because the account facts beside it (which enterprise account, when it was | ||
| 94 | * linked) describe a credential and belong under the same protection. | ||
| 95 | * | ||
| 96 | * Read it back with {@link decodeWorkIdentityRecord}; never with a cast. */ | ||
| 97 | export const WorkIdentityRecordSchema = Schema.Struct({ | ||
| 98 | /** The durable subject presented as `subject_token` on every later exchange: | ||
| 99 | * the IdP refresh token, or — when the IdP issued none — the ID token. */ | ||
| 100 | token: Schema.String, | ||
| 101 | /** RFC 8693 §3 type of `token`. Says which of the two custodies is in force. */ | ||
| 102 | tokenType: SubjectTokenTypeSchema, | ||
| 103 | /** The IdP's `sub` claim for this account. Stable across re-links; the | ||
| 104 | * identifier support and audit read. */ | ||
| 105 | subject: Schema.NullOr(Schema.String), | ||
| 106 | /** Display label from the ID token (`email`, else `preferred_username`, else | ||
| 107 | * `sub`) — what "linked as …" shows. */ | ||
| 108 | label: Schema.NullOr(Schema.String), | ||
| 109 | /** Epoch ms the link was made. */ | ||
| 110 | linkedAt: Schema.Number, | ||
| 111 | /** Epoch ms this custody stops working, when it is knowable — the ID token's | ||
| 112 | * `exp` under ID-token custody. Null for refresh-token custody: a refresh | ||
| 113 | * token has no client-visible expiry, which is the whole point of holding it. */ | ||
| 114 | expiresAt: Schema.NullOr(Schema.Number), | ||
| 115 | /** Scope the IdP granted on the link, as it echoed it. Recorded for support; | ||
| 116 | * never re-requested from here (each connect asks for the server's scopes). */ | ||
| 117 | scope: Schema.NullOr(Schema.String), | ||
| 118 | /** Set when the IdP rejected this subject. The record is KEPT so the product | ||
| 119 | * can say "re-link", and so every connection pointing at it short-circuits | ||
| 120 | * instead of re-spending a doomed exchange. */ | ||
| 121 | revokedAt: Schema.optional(Schema.Number), | ||
| 122 | revokedReason: Schema.optional(WorkIdentityRevocationReasonSchema), | ||
| 123 | }).annotate({ | ||
| 124 | identifier: "WorkIdentityRecord", | ||
| 125 | description: | ||
| 126 | "A user's held enterprise identity: the durable subject token plus the account facts describing it.", | ||
| 127 | }); | ||
| 128 | export interface WorkIdentityRecord extends Schema.Schema.Type<typeof WorkIdentityRecordSchema> {} | ||
| 129 | |||
| 130 | const WorkIdentityRecordFromJson = Schema.fromJsonString(WorkIdentityRecordSchema); | ||
| 131 | |||
| 132 | /** Parse a stored record. A record that does not decode is treated as ABSENT by | ||
| 133 | * callers — the product then says "not linked", which is both true (nothing | ||
| 134 | * usable is held) and recoverable (linking again overwrites it). */ | ||
| 135 | export const decodeWorkIdentityRecord = Schema.decodeUnknownOption(WorkIdentityRecordFromJson); | ||
| 136 | |||
| 137 | /** Serialize a record for the credential provider. */ | ||
| 138 | export const encodeWorkIdentityRecord = Schema.encodeSync(WorkIdentityRecordFromJson); | ||
| 139 | |||
| 140 | /** Whether this record can still be presented to the IdP. */ | ||
| 141 | export const isWorkIdentityUsable = (record: WorkIdentityRecord): boolean => | ||
| 142 | record.revokedAt === undefined; | ||
| 143 | |||
| 144 | /** The record with the IdP's rejection recorded. Idempotent: a record already | ||
| 145 | * marked keeps its FIRST rejection timestamp, so "since when" stays true when | ||
| 146 | * several connections meet the same dead identity. */ | ||
| 147 | export const revokedWorkIdentity = ( | ||
| 148 | record: WorkIdentityRecord, | ||
| 149 | input: { readonly at: number; readonly reason: WorkIdentityRevocationReason }, | ||
| 150 | ): WorkIdentityRecord => | ||
| 151 | record.revokedAt === undefined | ||
| 152 | ? { ...record, revokedAt: input.at, revokedReason: input.reason } | ||
| 153 | : record; | ||
| 154 | |||
| 155 | // --------------------------------------------------------------------------- | ||
| 156 | // The read model | ||
| 157 | // --------------------------------------------------------------------------- | ||
| 158 | |||
| 159 | const WorkIdentityAccount = { | ||
| 160 | /** The IdP's `sub` claim, when the ID token carried one. */ | ||
| 161 | subject: Schema.NullOr(Schema.String), | ||
| 162 | /** "Linked as …" — the account email, else `preferred_username`, else `sub`. */ | ||
| 163 | label: Schema.NullOr(Schema.String), | ||
| 164 | linkedAt: Schema.Number, | ||
| 165 | /** Which custody is in force. `…:refresh_token` survives ID-token expiry; | ||
| 166 | * `…:id_token` means the IdP issued no refresh token and this link dies at | ||
| 167 | * `expiresAt`. */ | ||
| 168 | subjectTokenType: SubjectTokenTypeSchema, | ||
| 169 | expiresAt: Schema.NullOr(Schema.Number), | ||
| 170 | } as const; | ||
| 171 | |||
| 172 | /** What the console polls. Three states, discriminated by `status`, because the | ||
| 173 | * product does three different things: | ||
| 174 | * | ||
| 175 | * - `unlinked` → offer "Link your work identity". | ||
| 176 | * - `linked` → show the account; enterprise-managed connects will work. | ||
| 177 | * - `needs_relink` → an identity IS held but the IdP has rejected it. Every | ||
| 178 | * enterprise-managed connection backed by it is stalled and ONE re-link | ||
| 179 | * revives all of them — which is why this is not the same state as | ||
| 180 | * `unlinked`, and emphatically not the same as a dead connection. */ | ||
| 181 | export const WorkIdentityStatusSchema = Schema.Union([ | ||
| 182 | Schema.Struct({ | ||
| 183 | status: Schema.Literal("unlinked"), | ||
| 184 | idpClient: OAuthClientSlug, | ||
| 185 | idpClientOwner: Owner, | ||
| 186 | owner: Owner, | ||
| 187 | }), | ||
| 188 | Schema.Struct({ | ||
| 189 | status: Schema.Literal("linked"), | ||
| 190 | idpClient: OAuthClientSlug, | ||
| 191 | idpClientOwner: Owner, | ||
| 192 | owner: Owner, | ||
| 193 | ...WorkIdentityAccount, | ||
| 194 | }), | ||
| 195 | Schema.Struct({ | ||
| 196 | status: Schema.Literal("needs_relink"), | ||
| 197 | idpClient: OAuthClientSlug, | ||
| 198 | idpClientOwner: Owner, | ||
| 199 | owner: Owner, | ||
| 200 | ...WorkIdentityAccount, | ||
| 201 | revokedAt: Schema.Number, | ||
| 202 | revokedReason: WorkIdentityRevocationReasonSchema, | ||
| 203 | }), | ||
| 204 | ]).annotate({ | ||
| 205 | identifier: "WorkIdentityStatus", | ||
| 206 | description: | ||
| 207 | "Whether a user holds a usable enterprise identity for an IdP app, and which account it is.", | ||
| 208 | }); | ||
| 209 | export type WorkIdentityStatus = typeof WorkIdentityStatusSchema.Type; | ||
| 210 | |||
| 211 | /** Project a stored record onto the read model. Carries the account facts and | ||
| 212 | * NOTHING that could stand in for the credential: `token` has no path here. */ | ||
| 213 | export const workIdentityStatusOf = ( | ||
| 214 | ref: WorkIdentityRef, | ||
| 215 | record: WorkIdentityRecord | null, | ||
| 216 | ): WorkIdentityStatus => { | ||
| 217 | if (record === null) { | ||
| 218 | return { | ||
| 219 | status: "unlinked", | ||
| 220 | owner: ref.owner, | ||
| 221 | idpClient: ref.idpClient, | ||
| 222 | idpClientOwner: ref.idpClientOwner, | ||
| 223 | }; | ||
| 224 | } | ||
| 225 | const account = { | ||
| 226 | owner: ref.owner, | ||
| 227 | idpClient: ref.idpClient, | ||
| 228 | idpClientOwner: ref.idpClientOwner, | ||
| 229 | subject: record.subject, | ||
| 230 | label: record.label, | ||
| 231 | linkedAt: record.linkedAt, | ||
| 232 | subjectTokenType: record.tokenType, | ||
| 233 | expiresAt: record.expiresAt, | ||
| 234 | } as const; | ||
| 235 | return record.revokedAt === undefined | ||
| 236 | ? { status: "linked", ...account } | ||
| 237 | : { | ||
| 238 | status: "needs_relink", | ||
| 239 | ...account, | ||
| 240 | revokedAt: record.revokedAt, | ||
| 241 | revokedReason: record.revokedReason ?? "rejected", | ||
| 242 | }; | ||
| 243 | }; | ||
| 244 | |||
| 245 | // --------------------------------------------------------------------------- | ||
| 246 | // Flow inputs | ||
| 247 | // --------------------------------------------------------------------------- | ||
| 248 | |||
| 249 | /** The default scope set a link requests. `openid` is what makes the token | ||
| 250 | * endpoint return an ID token, which is the only place the account facts come | ||
| 251 | * from; `offline_access` is how an OIDC provider is asked for the refresh token | ||
| 252 | * this whole design holds. Deliberately NOT narrowed against the IdP's | ||
| 253 | * advertised `scopes_supported`: both are standard OIDC scopes that many | ||
| 254 | * authorization servers decline to enumerate, and dropping either would quietly | ||
| 255 | * cost the link its account facts or its durability. An IdP that spells them | ||
| 256 | * differently is served by the explicit `scopes` override. */ | ||
| 257 | export const DEFAULT_WORK_IDENTITY_SCOPES: readonly string[] = ["openid", "offline_access"]; | ||
| 258 | |||
| 259 | export interface StartWorkIdentityLinkInput extends WorkIdentityRef { | ||
| 260 | /** Replace {@link DEFAULT_WORK_IDENTITY_SCOPES} outright. An override, not an | ||
| 261 | * addition: an IdP that rejects `offline_access` needs the default GONE, not | ||
| 262 | * supplemented. Omit unless the IdP requires it. */ | ||
| 263 | readonly scopes?: readonly string[]; | ||
| 264 | /** Browser-facing callback for this link. Defaults to the executor's | ||
| 265 | * configured work-identity callback. */ | ||
| 266 | readonly redirectUri?: string | null; | ||
| 267 | } | ||
| 268 | |||
| 269 | export interface CompleteWorkIdentityLinkInput { | ||
| 270 | readonly state: OAuthState; | ||
| 271 | readonly code: string; | ||
| 272 | } | ||
| 273 | |||
| 274 | // --------------------------------------------------------------------------- | ||
| 275 | // The in-flight session | ||
| 276 | // | ||
| 277 | // A link reuses `oauth_session` — the same table, TTL and cleanup the connect | ||
| 278 | // flow uses — rather than growing a parallel one. What distinguishes the two is | ||
| 279 | // the payload, and it is CHECKED rather than assumed: a work-identity state | ||
| 280 | // handed to `complete` must not be able to mint a connection out of the sentinel | ||
| 281 | // columns, and a connect state handed to `completeWorkIdentityLink` must not be | ||
| 282 | // able to redeem a code into someone's identity custody. | ||
| 283 | // --------------------------------------------------------------------------- | ||
| 284 | |||
| 285 | /** Value written to `oauth_session`'s integration/name/template columns for a | ||
| 286 | * link. They are non-null and mean nothing here — a link targets no | ||
| 287 | * integration — so they carry a self-describing sentinel instead of an empty | ||
| 288 | * string that would read as data. The payload is what completion parses. */ | ||
| 289 | export const WORK_IDENTITY_SESSION_SENTINEL = "work-identity"; | ||
| 290 | |||
| 291 | export const WorkIdentitySessionPayloadSchema = Schema.Struct({ | ||
| 292 | kind: Schema.Literal("work-identity"), | ||
| 293 | owner: Owner, | ||
| 294 | idpClient: OAuthClientSlug, | ||
| 295 | idpClientOwner: Owner, | ||
| 296 | /** What the authorize request asked for; the recorded-scope fallback when the | ||
| 297 | * IdP's token response omits `scope`. */ | ||
| 298 | requestedScopes: Schema.Array(Schema.String), | ||
| 299 | }).annotate({ identifier: "WorkIdentitySessionPayload" }); | ||
| 300 | export interface WorkIdentitySessionPayload extends Schema.Schema.Type< | ||
| 301 | typeof WorkIdentitySessionPayloadSchema | ||
| 302 | > {} | ||
| 303 | |||
| 304 | const decodeWorkIdentitySessionPayload = Schema.decodeUnknownOption( | ||
| 305 | WorkIdentitySessionPayloadSchema, | ||
| 306 | ); | ||
| 307 | |||
| 308 | /** The link this session belongs to, or null when the session is not one. The | ||
| 309 | * ONLY way either completion path decides which flow it is looking at. */ | ||
| 310 | export const workIdentitySessionPayloadFrom = ( | ||
| 311 | payload: unknown, | ||
| 312 | ): WorkIdentitySessionPayload | null => Option.getOrNull(decodeWorkIdentitySessionPayload(payload)); | ||
| 313 | |||
| 314 | // --------------------------------------------------------------------------- | ||
| 315 | // Errors | ||
| 316 | // --------------------------------------------------------------------------- | ||
| 317 | |||
| 318 | /** A work-identity link could not be started or completed. Deliberately its own | ||
| 319 | * tag rather than an `OAuthCompleteError`: nothing about it concerns a | ||
| 320 | * connection, and a console that renders "could not connect" for a failed link | ||
| 321 | * sends the user to the wrong place. */ | ||
| 322 | export class WorkIdentityLinkError extends Schema.TaggedErrorClass<WorkIdentityLinkError>()( | ||
| 323 | "WorkIdentityLinkError", | ||
| 324 | { | ||
| 325 | message: Schema.String, | ||
| 326 | /** True when the flow cannot be resumed and the user must start the link | ||
| 327 | * again (an expired or already-redeemed authorization). */ | ||
| 328 | restartRequired: Schema.optional(Schema.Boolean), | ||
| 329 | }, | ||
| 330 | ) { | ||
| 331 | readonly __executorUserActionable = true; | ||
| 332 | readonly code = "work_identity_link_error"; | ||
| 333 | |||
| 334 | get userMessage(): string { | ||
| 335 | return this.message; | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | /** The subject-token type a link takes custody of, given what the IdP returned. | ||
| 340 | * Refresh token when there is one (§4.5); ID token otherwise, which is custody | ||
| 341 | * of something that expires and is recorded as such. */ | ||
| 342 | export const workIdentityCustodyType = (input: { | ||
| 343 | readonly refreshToken: string | undefined; | ||
| 344 | readonly idToken: string | undefined; | ||
| 345 | }): SubjectTokenType | null => { | ||
| 346 | if (input.refreshToken !== undefined && input.refreshToken.length > 0) { | ||
| 347 | return "urn:ietf:params:oauth:token-type:refresh_token"; | ||
| 348 | } | ||
| 349 | if (input.idToken !== undefined && input.idToken.length > 0) { | ||
| 350 | return "urn:ietf:params:oauth:token-type:id_token"; | ||
| 351 | } | ||
| 352 | return null; | ||
| 353 | }; | ||
| @@ -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>:secret | 8 | // 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 acting | 11 | // Credential providers file plugin-storage rows by THIS owner, not the acting |
| 11 | // caller's binding — an org connection whose OAuth consent completes in one | 12 | // 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 none | 32 | /** The owner a logical item id embeds, or null for ids that carry none |
| @@ -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. | ||
| 182 | export { | ||
| 183 | DEFAULT_WORK_IDENTITY_SCOPES, | ||
| 184 | WorkIdentityLinkError, | ||
| 185 | WorkIdentityRefSchema, | ||
| 186 | WorkIdentityRevocationReasonSchema, | ||
| 187 | WorkIdentityStatusSchema, | ||
| 188 | type CompleteWorkIdentityLinkInput, | ||
| 189 | type StartWorkIdentityLinkInput, | ||
| 190 | type WorkIdentityRef, | ||
| 191 | type WorkIdentityRevocationReason, | ||
| 192 | type WorkIdentityStatus, | ||
| 193 | } from "./oauth-work-identity"; | ||
| 194 | |||
| 178 | // Wire-level HTTP error schema for plugin HttpApiGroup definitions. | 195 | // Wire-level HTTP error schema for plugin HttpApiGroup definitions. |
| 179 | export { InternalError } from "./api-errors"; | 196 | export { InternalError } from "./api-errors"; |
| 180 | 197 | ||
| @@ -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 expired | 158 | * 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 drives | 171 | * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives |
| 157 | * the case that only exists over time — an administrator withdrawing access | 172 | * 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, ""); |