History

Merge remote-tracking branch 'origin/codex/batch16-core-oauth-boundaries' into codex/effect-lint-integration

# Conflicts: # packages/core/sdk/src/oauth-discovery.ts # packages/plugins/openapi/src/sdk/oauth-refresh.test.ts

Rhys Sullivan committed 6 months ago

Commit e5b11962 parents 5d78d34 418b0962 files · +436 −596Browse files

Unified · Split

2 files
  1. packages/core/sdk/src/oauth-service.ts +268 −390
  2. packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +168 −206
packages/core/sdk/src/oauth-service.ts+268−390
@@ -35,13 +35,9 @@ · 34 unmodified lines
3535 // every strategy because refresh semantics are strategy-independent.
3636 // ---------------------------------------------------------------------------
3737
38−import { Effect, Schema } from "effect";
38+import { Effect, Option, Predicate, Schema } from "effect";
3939
40−import type {
41− DBAdapter,
42− StorageFailure,
43− TypedAdapter,
44−} from "@executor-js/storage-core";
40+import type { DBAdapter, StorageFailure, TypedAdapter } from "@executor-js/storage-core";
4541
4642 import {
4743 ConnectionRefreshError,
@@ -52,9 +48,7 @@ import { · 4 unmodified lines
5248 type ConnectionRefreshResult,
5349 type ConnectionRef,
5450 } from "./connections";
55−import type {
56− ConnectionProviderNotRegisteredError,
57−} from "./errors";
51+import type { ConnectionProviderNotRegisteredError } from "./errors";
5852 import type { CoreSchema } from "./core-schema";
5953 import { ConnectionId, ScopeId, SecretId } from "./ids";
6054 import { SetSecretInput, type SecretRef } from "./secrets";
@@ -100,6 +94,8 @@ import { · 39 unmodified lines
10094
10195 const OAuthAuthorizationServerMetadataJson = Schema.Record(Schema.String, Schema.Unknown);
10296 const OAuthClientInformationJson = Schema.Record(Schema.String, Schema.Unknown);
97+const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
98+const JsonValueSchema = Schema.fromJsonString(Schema.Unknown);
10399
104100 const DynamicDcrSessionPayload = Schema.Struct({
105101 kind: Schema.Literal("dynamic-dcr"),
@@ -110,9 +106,7 @@ const DynamicDcrSessionPayload = Schema.Struct({ · 4 unmodified lines
110106 authorizationServerMetadata: OAuthAuthorizationServerMetadataJson,
111107 clientInformation: OAuthClientInformationJson,
112108 resourceMetadataUrl: Schema.NullOr(Schema.String),
113− resourceMetadata: Schema.NullOr(
114− Schema.Record(Schema.String, Schema.Unknown),
115− ),
109+ resourceMetadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)),
116110 scopes: Schema.Array(Schema.String),
117111 });
118112
@@ -122,7 +116,9 @@ const AuthorizationCodeSessionPayload = Schema.Struct({ · 3 unmodified lines
122116 codeVerifier: Schema.String,
123117 authorizationEndpoint: Schema.String,
124118 tokenEndpoint: Schema.String,
125− issuerUrl: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed(null))),
119+ issuerUrl: Schema.NullOr(Schema.String).pipe(
120+ Schema.withDecodingDefaultType(Effect.succeed(null)),
121+ ),
126122 clientIdSecretId: Schema.String,
127123 clientSecretSecretId: Schema.NullOr(Schema.String),
128124 scopes: Schema.Array(Schema.String),
@@ -144,22 +140,22 @@ const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload); · 15 unmodified lines
144140
145141 const coerceJson = (value: unknown): unknown => {
146142 if (typeof value !== "string") return value;
147− try {
148− return JSON.parse(value);
149− } catch {
150− return value;
151− }
143+ const parsed = Schema.decodeUnknownOption(JsonValueSchema)(value);
144+ return Option.isSome(parsed) ? parsed.value : value;
152145 };
153146
154147 const stringArray = (value: unknown): readonly string[] =>
155− Array.isArray(value)
156− ? value.filter((scope): scope is string => typeof scope === "string")
157− : [];
148+ Array.isArray(value) ? value.filter((scope): scope is string => typeof scope === "string") : [];
158149
159150 const originOrNull = (value: unknown): string | null => {
160151 if (typeof value !== "string") return null;
152+ return parseUrlOption(value)?.origin ?? null;
153+};
154+
155+const parseUrlOption = (value: string): URL | null => {
156+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser
161157 try {
162− return new URL(value).origin;
158+ return new URL(value);
163159 } catch {
164160 return null;
165161 }
@@ -167,8 +163,8 @@ const originOrNull = (value: unknown): string | null => { · 1 unmodified line
167163
168164 const decodeProviderState = (value: unknown): OAuthProviderState => {
169165 const raw = coerceJson(value);
170− const record =
171− raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
166+ const decodedRecord = Schema.decodeUnknownOption(UnknownRecord)(raw);
167+ const record = Option.isSome(decodedRecord) ? decodedRecord.value : null;
172168
173169 if (record && !("kind" in record) && "flow" in record && "tokenUrl" in record) {
174170 const flow = record.flow;
@@ -196,12 +192,7 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 21 unmodified lines
196192 }
197193 }
198194
199− if (
200− record &&
201− !("kind" in record) &&
202− "clientIdSecretId" in record &&
203− "scopes" in record
204− ) {
195+ if (record && !("kind" in record) && "clientIdSecretId" in record && "scopes" in record) {
205196 const scopes = stringArray(record.scopes);
206197 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({
207198 kind: "authorization-code",
@@ -214,48 +205,38 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 6 unmodified lines
214205 });
215206 }
216207
217− if (
218− record &&
219− !("kind" in record) &&
220− "clientInformation" in record &&
221− "endpoint" in record
222− ) {
223− const clientInformation =
224− record.clientInformation && typeof record.clientInformation === "object"
225− ? (record.clientInformation as Record<string, unknown>)
226− : null;
208+ if (record && !("kind" in record) && "clientInformation" in record && "endpoint" in record) {
209+ const decodedClientInformation = Schema.decodeUnknownOption(UnknownRecord)(
210+ record.clientInformation,
211+ );
212+ const clientInformation = Option.isSome(decodedClientInformation)
213+ ? decodedClientInformation.value
214+ : null;
215+ const decodedAuthorizationServerMetadata = Schema.decodeUnknownOption(UnknownRecord)(
216+ record.authorizationServerMetadata,
217+ );
218+ const authorizationServerMetadata = Option.isSome(decodedAuthorizationServerMetadata)
219+ ? decodedAuthorizationServerMetadata.value
220+ : null;
227221 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({
228222 kind: "dynamic-dcr",
229223 tokenEndpoint:
230224 typeof record.tokenEndpoint === "string"
231225 ? record.tokenEndpoint
232− : record.authorizationServerMetadata &&
233− typeof record.authorizationServerMetadata === "object" &&
234− typeof (record.authorizationServerMetadata as Record<string, unknown>)
235− .token_endpoint === "string"
236− ? ((record.authorizationServerMetadata as Record<string, unknown>)
237− .token_endpoint as string)
226+ : typeof authorizationServerMetadata?.token_endpoint === "string"
227+ ? authorizationServerMetadata.token_endpoint
238228 : "",
239229 issuerUrl:
240− record.authorizationServerMetadata &&
241− typeof record.authorizationServerMetadata === "object" &&
242− typeof (record.authorizationServerMetadata as Record<string, unknown>).issuer ===
243− "string"
244− ? ((record.authorizationServerMetadata as Record<string, unknown>)
245− .issuer as string)
230+ typeof authorizationServerMetadata?.issuer === "string"
231+ ? authorizationServerMetadata.issuer
246232 : null,
247233 authorizationServerUrl:
248− typeof record.authorizationServerUrl === "string"
249− ? record.authorizationServerUrl
250− : null,
234+ typeof record.authorizationServerUrl === "string" ? record.authorizationServerUrl : null,
251235 authorizationServerMetadataUrl:
252236 typeof record.authorizationServerMetadataUrl === "string"
253237 ? record.authorizationServerMetadataUrl
254238 : null,
255− clientId:
256− typeof clientInformation?.client_id === "string"
257− ? clientInformation.client_id
258− : "",
239+ clientId: typeof clientInformation?.client_id === "string" ? clientInformation.client_id : "",
259240 clientSecretSecretId: null,
260241 clientAuth: "body",
261242 scope: null,
@@ -288,10 +269,7 @@ export interface OAuthServiceDeps { · 26 unmodified lines
288269 * `complete` (and from `start` for `client-credentials`). */
289270 readonly connectionsCreate: (
290271 input: CreateConnectionInput,
291− ) => Effect.Effect<
292− ConnectionRef,
293− ConnectionProviderNotRegisteredError | StorageFailure
294− >;
272+ ) => Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure>;
295273 /** Random session id generator. Tests override to make outputs
296274 * deterministic. */
297275 readonly newSessionId?: () => string;
@@ -304,9 +282,9 @@ const defaultSessionId = (): string => { · 6 unmodified lines
304282 if (crypto?.randomUUID) return `oauth2_session_${crypto.randomUUID()}`;
305283 const bytes = new Uint8Array(16);
306284 crypto.getRandomValues(bytes);
307− return `oauth2_session_${Array.from(bytes, (byte) =>
308− byte.toString(16).padStart(2, "0"),
309− ).join("")}`;
285+ return `oauth2_session_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
286+ "",
287+ )}`;
310288 };
311289
312290 const secretIdPart = (value: string): string =>
@@ -328,11 +306,7 @@ const oauthSecretId = ( · 15 unmodified lines
328306 const scopedSessionId = (scopeId: string, sessionId: string): string =>
329307 `${sessionId}_${secretIdPart(scopeId).slice(0, 24)}`;
330308
331−const terminalRefreshErrors = new Set([
332− "invalid_grant",
333− "invalid_client",
334− "unauthorized_client",
335−]);
309+const terminalRefreshErrors = new Set(["invalid_grant", "invalid_client", "unauthorized_client"]);
336310
337311 // ---------------------------------------------------------------------------
338312 // Service factory
@@ -347,19 +321,16 @@ export const makeOAuth2Service = ( · 8 unmodified lines
347321 // -------------------------------------------------------------------
348322 // probe
349323 // -------------------------------------------------------------------
350− const probe = (
351− input: OAuthProbeInput,
352− ): Effect.Effect<OAuthProbeResult, OAuthProbeError> =>
324+ const probe = (input: OAuthProbeInput): Effect.Effect<OAuthProbeResult, OAuthProbeError> =>
353325 Effect.gen(function* () {
354− const resource = yield* discoverProtectedResourceMetadata(
355− input.endpoint,
356− { resourceHeaders: input.headers, resourceQueryParams: input.queryParams },
357− ).pipe(
358− Effect.catchTag("OAuthDiscoveryError", (err) =>
326+ const resource = yield* discoverProtectedResourceMetadata(input.endpoint, {
327+ resourceHeaders: input.headers,
328+ resourceQueryParams: input.queryParams,
329+ }).pipe(
330+ Effect.catchTag("OAuthDiscoveryError", () =>
359331 Effect.fail(
360332 new OAuthProbeError({
361− message: `Protected resource metadata probe failed: ${err.message}`,
362−
333+ message: "Protected resource metadata probe failed",
363334 }),
364335 ),
365336 ),
@@ -368,29 +339,19 @@ export const makeOAuth2Service = ( · 2 unmodified lines
368339 const authorizationServerUrl = (() => {
369340 const fromResource = resource?.metadata.authorization_servers?.[0];
370341 if (fromResource) return fromResource;
371− try {
372− const u = new URL(input.endpoint);
373− return `${u.protocol}//${u.host}`;
374− } catch {
375− return null;
376− }
342+ const u = parseUrlOption(input.endpoint);
343+ return u ? `${u.protocol}//${u.host}` : null;
377344 })();
378345
379346 const authServer = authorizationServerUrl
380− ? yield* discoverAuthorizationServerMetadata(
381− authorizationServerUrl,
382− ).pipe(
383− Effect.catchTag("OAuthDiscoveryError", () =>
384− Effect.succeed(null),
385− ),
347+ ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl).pipe(
348+ Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)),
386349 )
387350 : null;
388351
389352 const supportsDynamicRegistration = !!(
390353 authServer?.metadata.registration_endpoint &&
391− (authServer.metadata.token_endpoint_auth_methods_supported ?? []).includes(
392− "none",
393− )
354+ (authServer.metadata.token_endpoint_auth_methods_supported ?? []).includes("none")
394355 );
395356
396357 // Bearer challenge probe — POST the endpoint unauth, look for
@@ -400,47 +361,40 @@ export const makeOAuth2Service = ( · 3 unmodified lines
400361 // challenge").
401362 const isBearerChallengeEndpoint = yield* Effect.tryPromise({
402363 try: async (): Promise<boolean> => {
403− const controller = new AbortController();
404− const timer = setTimeout(() => controller.abort(), 6_000);
405− try {
406− const probeUrl = new URL(input.endpoint);
407− for (const [key, value] of Object.entries(input.queryParams ?? {})) {
408− probeUrl.searchParams.set(key, value);
409− }
410− const response = await fetch(probeUrl.toString(), {
411− method: "POST",
412− headers: {
413− ...(input.headers ?? {}),
414− "content-type": "application/json",
415− accept: "application/json, text/event-stream",
416− },
417− body: JSON.stringify({
418− jsonrpc: "2.0",
419− id: 1,
420− method: "initialize",
421− params: {
422− protocolVersion: "2025-06-18",
423− capabilities: {},
424− clientInfo: { name: "executor-probe", version: "0" },
425− },
426− }),
427− signal: controller.signal,
428− });
429− if (response.status !== 401) return false;
430− const wwwAuth =
431− response.headers.get("www-authenticate") ??
432− response.headers.get("WWW-Authenticate");
433− return !!wwwAuth && /^\s*bearer\b/i.test(wwwAuth);
434− } finally {
435− clearTimeout(timer);
364+ const probeUrl = parseUrlOption(input.endpoint);
365+ if (!probeUrl) return false;
366+ for (const [key, value] of Object.entries(input.queryParams ?? {})) {
367+ probeUrl.searchParams.set(key, value);
436368 }
369+ const response = await fetch(probeUrl.toString(), {
370+ method: "POST",
371+ headers: {
372+ ...(input.headers ?? {}),
373+ "content-type": "application/json",
374+ accept: "application/json, text/event-stream",
375+ },
376+ body: JSON.stringify({
377+ jsonrpc: "2.0",
378+ id: 1,
379+ method: "initialize",
380+ params: {
381+ protocolVersion: "2025-06-18",
382+ capabilities: {},
383+ clientInfo: { name: "executor-probe", version: "0" },
384+ },
385+ }),
386+ signal: AbortSignal.timeout(6_000),
387+ });
388+ if (response.status !== 401) return false;
389+ const wwwAuth =
390+ response.headers.get("www-authenticate") ?? response.headers.get("WWW-Authenticate");
391+ return !!wwwAuth && /^\s*bearer\b/i.test(wwwAuth);
437392 },
438393 catch: () => null,
439394 }).pipe(Effect.catch(() => Effect.succeed(false)));
440395
441396 return {
442− resourceMetadata:
443− (resource?.metadata as Record<string, unknown> | undefined) ?? null,
397+ resourceMetadata: (resource?.metadata as Record<string, unknown> | undefined) ?? null,
444398 resourceMetadataUrl: resource?.metadataUrl ?? null,
445399 authorizationServerMetadata:
446400 (authServer?.metadata as Record<string, unknown> | undefined) ?? null,
@@ -459,20 +413,22 @@ export const makeOAuth2Service = ( · 12 unmodified lines
459413 strategy: OAuthDynamicDcrStrategy,
460414 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
461415 Effect.gen(function* () {
462− const started = yield* beginDynamicAuthorization({
463− endpoint: input.endpoint,
464− redirectUrl: input.redirectUrl,
465− state: "",
466− scopes: strategy.scopes,
467− }, {
468− resourceHeaders: input.headers,
469− resourceQueryParams: input.queryParams,
470− }).pipe(
471− Effect.catchTag("OAuthDiscoveryError", (err) =>
416+ const started = yield* beginDynamicAuthorization(
417+ {
418+ endpoint: input.endpoint,
419+ redirectUrl: input.redirectUrl,
420+ state: "",
421+ scopes: strategy.scopes,
422+ },
423+ {
424+ resourceHeaders: input.headers,
425+ resourceQueryParams: input.queryParams,
426+ },
427+ ).pipe(
428+ Effect.catchTag("OAuthDiscoveryError", () =>
472429 Effect.fail(
473430 new OAuthStartError({
474− message: `Dynamic authorization setup failed: ${err.message}`,
475−
431+ message: "Dynamic authorization setup failed",
476432 }),
477433 ),
478434 ),
@@ -492,10 +448,7 @@ export const makeOAuth2Service = ( · 13 unmodified lines
492448 authorizationUrl: started.state.authorizationServerMetadata.authorization_endpoint,
493449 clientId: started.state.clientInformation.client_id,
494450 redirectUrl: input.redirectUrl,
495− scopes:
496− strategy.scopes ??
497− started.state.authorizationServerMetadata.scopes_supported ??
498− [],
451+ scopes: strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? [],
499452 state: sessionId,
500453 codeChallenge,
501454 });
@@ -505,22 +458,20 @@ export const makeOAuth2Service = ( · 3 unmodified lines
505458 identityLabel: input.identityLabel ?? null,
506459 codeVerifier: started.codeVerifier,
507460 authorizationServerUrl: started.state.authorizationServerUrl,
508− authorizationServerMetadataUrl:
509− started.state.authorizationServerMetadataUrl,
510− authorizationServerMetadata:
511− started.state.authorizationServerMetadata as Record<string, unknown>,
461+ authorizationServerMetadataUrl: started.state.authorizationServerMetadataUrl,
462+ authorizationServerMetadata: started.state.authorizationServerMetadata as Record<
463+ string,
464+ unknown
465+ >,
512466 clientInformation: (() => {
513467 const value: unknown = started.state.clientInformation;
514468 return value as Record<string, unknown>;
515469 })(),
516470 resourceMetadataUrl: started.state.resourceMetadataUrl,
517471 resourceMetadata:
518− (started.state.resourceMetadata as Record<string, unknown> | null) ??
519− null,
472+ (started.state.resourceMetadata as Record<string, unknown> | null) ?? null,
520473 scopes: [
521− ...(strategy.scopes ??
522− started.state.authorizationServerMetadata.scopes_supported ??
523− []),
474+ ...(strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? []),
524475 ],
525476 };
526477
@@ -544,25 +495,22 @@ export const makeOAuth2Service = ( · 17 unmodified lines
544495 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
545496 Effect.gen(function* () {
546497 const clientId = yield* deps.secretsGet(strategy.clientIdSecretId).pipe(
547− Effect.mapError((err) =>
548− // Storage failure propagates; null returns aren't errors — the
549− // branch below handles them.
550− err,
498+ Effect.mapError(
499+ (err) =>
500+ // Storage failure propagates; null returns aren't errors — the
501+ // branch below handles them.
502+ err,
551503 ),
552504 );
553505 if (clientId === null) {
554− return yield* Effect.fail(
555− new OAuthStartError({
556− message: `client_id secret "${strategy.clientIdSecretId}" not found`,
557− }),
558− );
506+ return yield* new OAuthStartError({
507+ message: `client_id secret "${strategy.clientIdSecretId}" not found`,
508+ });
559509 }
560510
561511 const sessionId = scopedSessionId(input.tokenScope, newSessionId());
562512 const codeVerifier = createPkceCodeVerifier();
563− const codeChallenge = yield* Effect.promise(() =>
564− createPkceCodeChallenge(codeVerifier),
565− );
513+ const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
566514
567515 const authorizationUrl = buildAuthorizationUrl({
568516 authorizationUrl: strategy.authorizationEndpoint,
@@ -611,11 +559,9 @@ export const makeOAuth2Service = ( · 42 unmodified lines
611559 const clientId = yield* deps.secretsGet(strategy.clientIdSecretId);
612560 const clientSecret = yield* deps.secretsGet(strategy.clientSecretSecretId);
613561 if (clientId === null || clientSecret === null) {
614− return yield* Effect.fail(
615− new OAuthStartError({
616− message: "client_id / client_secret secret not found",
617− }),
618− );
562+ return yield* new OAuthStartError({
563+ message: "client_id / client_secret secret not found",
564+ });
619565 }
620566
621567 const tokens = yield* exchangeClientCredentials({
@@ -627,18 +573,15 @@ export const makeOAuth2Service = ( · 5 unmodified lines
627573 clientAuth: strategy.clientAuth ?? "body",
628574 }).pipe(
629575 Effect.mapError(
630− (err) =>
576+ () =>
631577 new OAuthStartError({
632− message: `Client credentials exchange failed: ${err.message}`,
633−
578+ message: "Client credentials exchange failed",
634579 }),
635580 ),
636581 );
637582
638583 const expiresAt =
639− typeof tokens.expires_in === "number"
640− ? now() + tokens.expires_in * 1000
641− : null;
584+ typeof tokens.expires_in === "number" ? now() + tokens.expires_in * 1000 : null;
642585
643586 const providerState: OAuthProviderState = {
644587 kind: "client-credentials",
@@ -666,19 +609,17 @@ export const makeOAuth2Service = ( · 21 unmodified lines
666609 refreshToken: null,
667610 expiresAt,
668611 oauthScope: tokens.scope ?? null,
669− providerState: Schema.encodeSync(OAuthProviderStateSchema)(
670− providerState,
671− ) as Record<string, unknown>,
612+ providerState: Schema.encodeSync(OAuthProviderStateSchema)(providerState) as Record<
613+ string,
614+ unknown
615+ >,
672616 }),
673617 )
674618 .pipe(
675619 Effect.mapError(
676− (err) =>
620+ () =>
677621 new OAuthStartError({
678− message: `Failed to mint connection: ${
679− err instanceof Error ? err.message : String(err)
680− }`,
681−
622+ message: "Failed to mint connection",
682623 }),
683624 ),
684625 );
@@ -709,22 +650,24 @@ export const makeOAuth2Service = ( · 24 unmodified lines
709650 payload: OAuthSessionPayload;
710651 strategyKind: string;
711652 }): Effect.Effect<void, StorageFailure> =>
712− deps.adapter.create({
713− model: "oauth2_session",
714− data: {
715− id: args.sessionId,
716− scope_id: args.input.tokenScope,
717− plugin_id: args.input.pluginId,
718− strategy: args.strategyKind,
719− connection_id: args.input.connectionId,
720− token_scope: args.input.tokenScope,
721− redirect_url: args.input.redirectUrl,
722− payload: encodeSessionPayload(args.payload) as Record<string, unknown>,
723− expires_at: now() + OAUTH2_SESSION_TTL_MS,
724− created_at: new Date(),
725− },
726− forceAllowId: true,
727− }).pipe(Effect.asVoid);
653+ deps.adapter
654+ .create({
655+ model: "oauth2_session",
656+ data: {
657+ id: args.sessionId,
658+ scope_id: args.input.tokenScope,
659+ plugin_id: args.input.pluginId,
660+ strategy: args.strategyKind,
661+ connection_id: args.input.connectionId,
662+ token_scope: args.input.tokenScope,
663+ redirect_url: args.input.redirectUrl,
664+ payload: encodeSessionPayload(args.payload) as Record<string, unknown>,
665+ expires_at: now() + OAUTH2_SESSION_TTL_MS,
666+ created_at: new Date(),
667+ },
668+ forceAllowId: true,
669+ })
670+ .pipe(Effect.asVoid);
728671
729672 // -------------------------------------------------------------------
730673 // complete — exchange the code, mint the Connection, delete the session
@@ -741,53 +684,45 @@ export const makeOAuth2Service = ( · 10 unmodified lines
741684 where: [{ field: "id", value: input.state }],
742685 });
743686 if (!row) {
744− return yield* Effect.fail(
745− new OAuthSessionNotFoundError({ sessionId: input.state }),
746− );
687+ return yield* new OAuthSessionNotFoundError({ sessionId: input.state });
747688 }
748689
749690 const deleteSession = deps.adapter.delete({
750691 model: "oauth2_session",
751692 where: [
752693 { field: "id", value: input.state },
753− { field: "scope_id", value: row.scope_id as string },
694+ { field: "scope_id", value: row.scope_id },
754695 ],
755696 });
756697
757698 if (input.error) {
758699 yield* deleteSession;
759− return yield* Effect.fail(
760− new OAuthCompleteError({
761− message: `Authorization server returned error: ${input.error}`,
762− code: input.error,
763− }),
764− );
700+ return yield* new OAuthCompleteError({
701+ message: `Authorization server returned error: ${input.error}`,
702+ code: input.error,
703+ });
765704 }
766705 if (!input.code) {
767706 yield* deleteSession;
768− return yield* Effect.fail(
769− new OAuthCompleteError({
770− message: "Missing authorization code",
771− }),
772− );
707+ return yield* new OAuthCompleteError({
708+ message: "Missing authorization code",
709+ });
773710 }
774711 const expiresAt = Number(row.expires_at as number | bigint);
775712 if (expiresAt <= now()) {
776713 yield* deleteSession;
777− return yield* Effect.fail(
778− new OAuthCompleteError({
779− message: "OAuth session expired",
780− }),
781− );
714+ return yield* new OAuthCompleteError({
715+ message: "OAuth session expired",
716+ });
782717 }
783718
784719 const payload = decodeSessionPayload(coerceJson(row.payload));
785720 const endpoint = ""; // not stored on the row — the payload's own
786− // endpoint fields drive exchange; we just need
787− // a display string for the identity label.
788− const connectionId = row.connection_id as string;
789− const tokenScope = row.token_scope as string;
790− const redirectUrl = row.redirect_url as string;
721+ // endpoint fields drive exchange; we just need
722+ // a display string for the identity label.
723+ const connectionId = row.connection_id;
724+ const tokenScope = row.token_scope;
725+ const redirectUrl = row.redirect_url;
791726
792727 // Dispatch to the strategy-specific exchange.
793728 const exchangeResult = yield* (() => {
@@ -795,11 +730,7 @@ export const makeOAuth2Service = ( · 1 unmodified line
795730 case "dynamic-dcr":
796731 return exchangeDynamicDcr(payload, input.code, redirectUrl);
797732 case "authorization-code":
798− return exchangeAuthorizationCodeStrategy(
799− payload,
800− input.code,
801− redirectUrl,
802− );
733+ return exchangeAuthorizationCodeStrategy(payload, input.code, redirectUrl);
803734 }
804735 })().pipe(Effect.tapError(() => deleteSession));
805736
@@ -828,11 +759,9 @@ export const makeOAuth2Service = ( · 22 unmodified lines
828759 .pipe(
829760 Effect.as(secretId),
830761 Effect.mapError(
831− (err) =>
762+ () =>
832763 new OAuthCompleteError({
833− message: `Failed to persist DCR client_secret: ${
834− err instanceof Error ? err.message : String(err)
835− }`,
764+ message: "Failed to persist DCR client_secret",
836765 }),
837766 ),
838767 );
@@ -842,21 +771,21 @@ export const makeOAuth2Service = ( · 3 unmodified lines
842771 payload.kind === "dynamic-dcr"
843772 ? {
844773 kind: "dynamic-dcr",
845− tokenEndpoint: (payload.authorizationServerMetadata as {
846− token_endpoint: string;
847− }).token_endpoint,
774+ tokenEndpoint: (
775+ payload.authorizationServerMetadata as {
776+ token_endpoint: string;
777+ }
778+ ).token_endpoint,
848779 issuerUrl:
849− (payload.authorizationServerMetadata as { issuer?: string }).issuer ??
850− null,
780+ (payload.authorizationServerMetadata as { issuer?: string }).issuer ?? null,
851781 authorizationServerUrl: payload.authorizationServerUrl,
852− authorizationServerMetadataUrl:
853− payload.authorizationServerMetadataUrl,
854− idTokenSigningAlgValuesSupported:
855− (payload.authorizationServerMetadata as {
782+ authorizationServerMetadataUrl: payload.authorizationServerMetadataUrl,
783+ idTokenSigningAlgValuesSupported: (
784+ payload.authorizationServerMetadata as {
856785 id_token_signing_alg_values_supported?: string[];
857− }).id_token_signing_alg_values_supported,
858− clientId: (payload.clientInformation as { client_id: string })
859− .client_id,
786+ }
787+ ).id_token_signing_alg_values_supported,
788+ clientId: (payload.clientInformation as { client_id: string }).client_id,
860789 clientSecretSecretId: dynamicClientSecretSecretId,
861790 clientAuth:
862791 (payload.clientInformation as { token_endpoint_auth_method?: string })
@@ -901,19 +830,17 @@ export const makeOAuth2Service = ( · 38 unmodified lines
901830 : null,
902831 expiresAt: connectionExpiresAt,
903832 oauthScope: exchangeResult.tokens.scope ?? null,
904− providerState: Schema.encodeSync(OAuthProviderStateSchema)(
905− providerState,
906− ) as Record<string, unknown>,
833+ providerState: Schema.encodeSync(OAuthProviderStateSchema)(providerState) as Record<
834+ string,
835+ unknown
836+ >,
907837 }),
908838 )
909839 .pipe(
910840 Effect.mapError(
911− (err) =>
841+ () =>
912842 new OAuthCompleteError({
913− message: `Failed to mint connection: ${
914− err instanceof Error ? err.message : String(err)
915− }`,
916−
843+ message: "Failed to mint connection",
917844 }),
918845 ),
919846 );
@@ -962,19 +889,14 @@ export const makeOAuth2Service = ( · 42 unmodified lines
962889 redirectUrl,
963890 codeVerifier: payload.codeVerifier,
964891 code,
965− idTokenSigningAlgValuesSupported:
966− md.id_token_signing_alg_values_supported,
967− clientAuth:
968− ci.token_endpoint_auth_method === "client_secret_basic"
969− ? "basic"
970− : "body",
892+ idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported,
893+ clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body",
971894 }).pipe(
972895 Effect.mapError(
973896 (err) =>
974897 new OAuthCompleteError({
975− message: `Token exchange failed: ${err.message}`,
898+ message: "Token exchange failed",
976899 code: err.error,
977−
978900 }),
979901 ),
980902 );
@@ -988,28 +910,21 @@ export const makeOAuth2Service = ( · 7 unmodified lines
988910 payload: Extract<OAuthSessionPayload, { kind: "authorization-code" }>,
989911 code: string,
990912 redirectUrl: string,
991− ): Effect.Effect<
992− ExchangeResult,
993− OAuthCompleteError | StorageFailure
994− > =>
913+ ): Effect.Effect<ExchangeResult, OAuthCompleteError | StorageFailure> =>
995914 Effect.gen(function* () {
996915 const clientId = yield* deps.secretsGet(payload.clientIdSecretId);
997916 if (clientId === null) {
998− return yield* Effect.fail(
999− new OAuthCompleteError({
1000− message: `client_id secret "${payload.clientIdSecretId}" not found`,
1001− }),
1002− );
917+ return yield* new OAuthCompleteError({
918+ message: `client_id secret "${payload.clientIdSecretId}" not found`,
919+ });
1003920 }
1004921 const clientSecret = payload.clientSecretSecretId
1005922 ? yield* deps.secretsGet(payload.clientSecretSecretId)
1006923 : null;
1007924 if (payload.clientSecretSecretId && clientSecret === null) {
1008− return yield* Effect.fail(
1009− new OAuthCompleteError({
1010− message: `client_secret secret "${payload.clientSecretSecretId}" not found`,
1011− }),
1012− );
925+ return yield* new OAuthCompleteError({
926+ message: `client_secret secret "${payload.clientSecretSecretId}" not found`,
927+ });
1013928 }
1014929
1015930 const tokens = yield* exchangeAuthorizationCode({
@@ -1025,9 +940,8 @@ export const makeOAuth2Service = ( · 9 unmodified lines
1025940 Effect.mapError(
1026941 (err) =>
1027942 new OAuthCompleteError({
1028− message: `Token exchange failed: ${err.message}`,
943+ message: "Token exchange failed",
1029944 code: err.error,
1030−
1031945 }),
1032946 ),
1033947 );
@@ -1048,7 +962,7 @@ export const makeOAuth2Service = ( · 14 unmodified lines
1048962 model: "oauth2_session",
1049963 where: [
1050964 { field: "id", value: sessionId },
1051− { field: "scope_id", value: row.scope_id as string },
965+ { field: "scope_id", value: row.scope_id },
1052966 ],
1053967 });
1054968 });
@@ -1071,9 +985,7 @@ export const makeOAuth2Service = ( · 16 unmodified lines
1071985 catch: (cause) =>
1072986 new ConnectionRefreshError({
1073987 connectionId: input.connectionId,
1074− message: `oauth2 providerState is malformed: ${
1075− cause instanceof Error ? cause.message : String(cause)
1076− }`,
988+ message: "oauth2 providerState is malformed",
1077989 cause,
1078990 }),
1079991 });
@@ -1095,22 +1007,16 @@ export const makeOAuth2Service = ( · 15 unmodified lines
10951007 case "dynamic-dcr":
10961008 return Effect.gen(function* () {
10971009 const csec = state.clientSecretSecretId
1098− ? yield* deps
1099− .secretsGet(state.clientSecretSecretId)
1100− .pipe(
1101− Effect.mapError(
1102− (cause) =>
1103− new ConnectionRefreshError({
1104− connectionId: input.connectionId,
1105− message: `Failed to resolve DCR client_secret: ${
1106− cause instanceof Error
1107− ? cause.message
1108− : String(cause)
1109− }`,
1110− cause,
1111− }),
1112− ),
1113− )
1010+ ? yield* deps.secretsGet(state.clientSecretSecretId).pipe(
1011+ Effect.mapError(
1012+ (cause) =>
1013+ new ConnectionRefreshError({
1014+ connectionId: input.connectionId,
1015+ message: "Failed to resolve DCR client_secret",
1016+ cause,
1017+ }),
1018+ ),
1019+ )
11141020 : null;
11151021 if (state.clientSecretSecretId && csec === null) {
11161022 return yield* new ConnectionRefreshError({
@@ -1124,22 +1030,16 @@ export const makeOAuth2Service = ( · 7 unmodified lines
11241030 case "authorization-code":
11251031 case "client-credentials":
11261032 return Effect.gen(function* () {
1127− const cid = yield* deps
1128− .secretsGet(state.clientIdSecretId)
1129− .pipe(
1130− Effect.mapError(
1131− (cause) =>
1132− new ConnectionRefreshError({
1133− connectionId: input.connectionId,
1134− message: `Failed to resolve client_id secret: ${
1135− cause instanceof Error
1136− ? cause.message
1137− : String(cause)
1138− }`,
1139− cause,
1140− }),
1141− ),
1142− );
1033+ const cid = yield* deps.secretsGet(state.clientIdSecretId).pipe(
1034+ Effect.mapError(
1035+ (cause) =>
1036+ new ConnectionRefreshError({
1037+ connectionId: input.connectionId,
1038+ message: "Failed to resolve client_id secret",
1039+ cause,
1040+ }),
1041+ ),
1042+ );
11431043 if (cid === null) {
11441044 return yield* new ConnectionRefreshError({
11451045 connectionId: input.connectionId,
@@ -1148,22 +1048,16 @@ export const makeOAuth2Service = ( · 2 unmodified lines
11481048 });
11491049 }
11501050 const csec = state.clientSecretSecretId
1151− ? yield* deps
1152− .secretsGet(state.clientSecretSecretId)
1153− .pipe(
1154− Effect.mapError(
1155− (cause) =>
1156− new ConnectionRefreshError({
1157− connectionId: input.connectionId,
1158− message: `Failed to resolve client_secret: ${
1159− cause instanceof Error
1160− ? cause.message
1161− : String(cause)
1162− }`,
1163− cause,
1164− }),
1165− ),
1166− )
1051+ ? yield* deps.secretsGet(state.clientSecretSecretId).pipe(
1052+ Effect.mapError(
1053+ (cause) =>
1054+ new ConnectionRefreshError({
1055+ connectionId: input.connectionId,
1056+ message: "Failed to resolve client_secret",
1057+ cause,
1058+ }),
1059+ ),
1060+ )
11671061 : null;
11681062 if (state.clientSecretSecretId && csec === null) {
11691063 return yield* new ConnectionRefreshError({
@@ -1179,32 +1073,25 @@ export const makeOAuth2Service = ( · 9 unmodified lines
11791073
11801074 const tokenEndpoint = yield* (() => {
11811075 if (state.tokenEndpoint) return Effect.succeed(state.tokenEndpoint);
1182− if (
1183− state.kind === "dynamic-dcr" &&
1184− state.authorizationServerUrl
1185− ) {
1186− return discoverAuthorizationServerMetadata(
1187− state.authorizationServerUrl,
1188− ).pipe(
1076+ if (state.kind === "dynamic-dcr" && state.authorizationServerUrl) {
1077+ return discoverAuthorizationServerMetadata(state.authorizationServerUrl).pipe(
11891078 Effect.flatMap((metadata) =>
11901079 metadata?.metadata.token_endpoint
11911080 ? Effect.succeed(metadata.metadata.token_endpoint)
11921081 : Effect.fail(
11931082 new ConnectionRefreshError({
11941083 connectionId: input.connectionId,
1195− message:
1196− "oauth2 legacy MCP providerState is missing token endpoint",
1084+ message: "oauth2 legacy MCP providerState is missing token endpoint",
11971085 reauthRequired: true,
11981086 }),
11991087 ),
12001088 ),
12011089 Effect.mapError((cause) =>
1202− cause instanceof ConnectionRefreshError
1090+ Predicate.isTagged("ConnectionRefreshError")(cause)
12031091 ? cause
12041092 : new ConnectionRefreshError({
12051093 connectionId: input.connectionId,
1206− message:
1207− "Failed to discover token endpoint for legacy MCP OAuth connection",
1094+ message: "Failed to discover token endpoint for legacy MCP OAuth connection",
12081095 reauthRequired: true,
12091096 cause,
12101097 }),
@@ -1220,56 +1107,51 @@ export const makeOAuth2Service = ( · 9 unmodified lines
12201107 );
12211108 })();
12221109
1223− const tokens = yield* (state.kind === "client-credentials"
1224− ? exchangeClientCredentials({
1225− tokenUrl: tokenEndpoint,
1226− clientId,
1227− clientSecret: clientSecret ?? "",
1228− scopes: state.scopes,
1229− scopeSeparator: state.scopeSeparator,
1230− clientAuth: state.clientAuth,
1231− })
1232− : refreshAccessToken({
1233− tokenUrl: tokenEndpoint,
1234− issuerUrl:
1235− state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1236− ? (state.issuerUrl ?? undefined)
1237− : undefined,
1238− clientId,
1239− clientSecret: clientSecret ?? undefined,
1240− refreshToken: input.refreshToken!,
1241− scopes:
1242− state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1243− ? state.scopes
1244− : undefined,
1245− scopeSeparator:
1246− state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1247− ? state.scopeSeparator
1248− : undefined,
1249− clientAuth: state.clientAuth,
1250− idTokenSigningAlgValuesSupported:
1251− state.kind === "dynamic-dcr"
1252− ? state.idTokenSigningAlgValuesSupported
1253− : undefined,
1254− })).pipe(
1110+ const tokens = yield* (
1111+ state.kind === "client-credentials"
1112+ ? exchangeClientCredentials({
1113+ tokenUrl: tokenEndpoint,
1114+ clientId,
1115+ clientSecret: clientSecret ?? "",
1116+ scopes: state.scopes,
1117+ scopeSeparator: state.scopeSeparator,
1118+ clientAuth: state.clientAuth,
1119+ })
1120+ : refreshAccessToken({
1121+ tokenUrl: tokenEndpoint,
1122+ issuerUrl:
1123+ state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1124+ ? (state.issuerUrl ?? undefined)
1125+ : undefined,
1126+ clientId,
1127+ clientSecret: clientSecret ?? undefined,
1128+ refreshToken: input.refreshToken!,
1129+ scopes:
1130+ state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1131+ ? state.scopes
1132+ : undefined,
1133+ scopeSeparator:
1134+ state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1135+ ? state.scopeSeparator
1136+ : undefined,
1137+ clientAuth: state.clientAuth,
1138+ idTokenSigningAlgValuesSupported:
1139+ state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined,
1140+ })
1141+ ).pipe(
12551142 Effect.mapError(
12561143 (err) =>
12571144 new ConnectionRefreshError({
12581145 connectionId: input.connectionId,
1259− message: `OAuth refresh failed: ${err.message}`,
1146+ message: "OAuth refresh failed",
12601147 // Terminal RFC 6749 §5.2 errors mean retrying won't heal it.
1261− reauthRequired: err.error
1262− ? terminalRefreshErrors.has(err.error)
1263− : false,
1264−
1148+ reauthRequired: err.error ? terminalRefreshErrors.has(err.error) : false,
12651149 }),
12661150 ),
12671151 );
12681152
12691153 const expiresAt =
1270− typeof tokens.expires_in === "number"
1271− ? now() + tokens.expires_in * 1000
1272− : null;
1154+ typeof tokens.expires_in === "number" ? now() + tokens.expires_in * 1000 : null;
12731155
12741156 const result: ConnectionRefreshResult = {
12751157 accessToken: tokens.access_token,
@@ -1293,9 +1175,5 @@ export const makeOAuth2Service = ( · 17 unmodified lines
12931175
12941176 const safeHostname = (value: string | null): string | null => {
12951177 if (!value) return null;
1296− try {
1297− return new URL(value).host;
1298− } catch {
1299− return value;
1300− }
1178+ return parseUrlOption(value)?.host ?? value;
13011179 };
packages/plugins/openapi/src/sdk/oauth-refresh.test.ts+168−206
@@ -13,8 +13,14 @@ · 12 unmodified lines
1313 // ---------------------------------------------------------------------------
1414
1515 import { afterEach, expect, layer } from "@effect/vitest";
16−import { Effect, Layer, Schema } from "effect";
17−import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";
16+import { Effect, Layer, Predicate, Schema } from "effect";
17+import {
18+ HttpApi,
19+ HttpApiBuilder,
20+ HttpApiEndpoint,
21+ HttpApiGroup,
22+ OpenApi,
23+} from "effect/unstable/httpapi";
1824 import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";
1925 import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2026
@@ -40,13 +46,6 @@ import { OAuth2Auth } from "./types"; · 19 unmodified lines
4046
4147 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
4248
43−class OpenApiOauthTestSetupError extends Schema.TaggedErrorClass<OpenApiOauthTestSetupError>()(
44− "OpenApiOauthTestSetupError",
45− {
46− message: Schema.String,
47− },
48−) {}
49−
5049 // ---------------------------------------------------------------------------
5150 // Test API — one endpoint that echoes the Authorization header so we can
5251 // prove which access token was in flight at invoke time.
@@ -96,23 +95,26 @@ const mockTokenFetch = ( · 43 unmodified lines
9695 handler: (body: URLSearchParams) => Effect.Effect<Response, never, never> | Promise<Response>,
9796 ) => {
9897 const calls: TokenCall[] = [];
99− globalThis.fetch = Object.assign(async (_input: RequestInfo | URL, init?: RequestInit) => {
100− const url = typeof _input === "string" ? _input : _input.toString();
101− if (!url.includes("token.example.com")) {
102− return originalFetch(_input, init);
103− }
104− const bodyText =
105− init?.body instanceof URLSearchParams
106− ? init.body.toString()
107− : typeof init?.body === "string"
108− ? init.body
109− : "";
110− const body = new URLSearchParams(bodyText);
111− calls.push({ body });
112− const out = handler(body);
113− if (Effect.isEffect(out)) return await Effect.runPromise(out);
114− return await out;
115− }, { preconnect: originalFetch.preconnect });
98+ globalThis.fetch = Object.assign(
99+ async (_input: RequestInfo | URL, init?: RequestInit) => {
100+ const url = typeof _input === "string" ? _input : _input.toString();
101+ if (!url.includes("token.example.com")) {
102+ return originalFetch(_input, init);
103+ }
104+ const bodyText =
105+ init?.body instanceof URLSearchParams
106+ ? init.body.toString()
107+ : typeof init?.body === "string"
108+ ? init.body
109+ : "";
110+ const body = new URLSearchParams(bodyText);
111+ calls.push({ body });
112+ const out = handler(body);
113+ if (Effect.isEffect(out)) return await Effect.runPromise(out);
114+ return await out;
115+ },
116+ { preconnect: originalFetch.preconnect },
117+ );
116118 return { calls };
117119 };
118120
@@ -133,14 +135,12 @@ const makeExecutor = () => · 14 unmodified lines
133135 const memoryProvider: SecretProvider = {
134136 key: "memory",
135137 writable: true,
136− get: (id, scope) =>
137− Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null),
138+ get: (id, scope) => Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null),
138139 set: (id, value, scope) =>
139140 Effect.sync(() => {
140141 secretStore.set(keyOf(scope, id), value);
141142 }),
142− delete: (id, scope) =>
143− Effect.sync(() => secretStore.delete(keyOf(scope, id))),
143+ delete: (id, scope) => Effect.sync(() => secretStore.delete(keyOf(scope, id))),
144144 };
145145 const memorySecretsPlugin = definePlugin(() => ({
146146 id: "memory-secrets" as const,
@@ -150,10 +150,9 @@ const makeExecutor = () => · 3 unmodified lines
150150 const clientLayer = FetchHttpClient.layer;
151151 const server = yield* HttpServer.HttpServer;
152152 const address = server.address;
153− if (!("port" in address)) {
154− return yield* new OpenApiOauthTestSetupError({
155− message: "Test server must bind to TCP",
156− });
153+ if (!Predicate.isTagged("TcpAddress")(address)) {
154+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test harness cannot continue without a TCP test server address
155+ return yield* Effect.die("test server must bind to TCP");
157156 }
158157 const baseUrl = `http://127.0.0.1:${address.port}`;
159158 const plugins = [
@@ -208,11 +207,7 @@ type ExecutorValue = EffectSuccess<ReturnType<typeof makeExecutor>>["executor"]; · 48 unmodified lines
208207 // Seed an authorizationCode Connection with an already-expired access
209208 // token and a stored refresh token. The test's mock token endpoint
210209 // decides what comes back on `grant_type=refresh_token`.
211−const seedExpiredConnection = (
212− executor: ExecutorValue,
213− scopeId: ScopeId,
214− connectionId: string,
215−) =>
210+const seedExpiredConnection = (executor: ExecutorValue, scopeId: ScopeId, connectionId: string) =>
216211 Effect.gen(function* () {
217212 yield* executor.connections.create(
218213 new CreateConnectionInput({
@@ -259,177 +254,144 @@ const seedExpiredConnection = ( · 40 unmodified lines
259254 // ---------------------------------------------------------------------------
260255
261256 layer(TestLayer)("OpenAPI oauth refresh", (it) => {
262− it.effect(
263− "expired access_token is refreshed via grant_type=refresh_token before invoke",
264− () =>
265− Effect.gen(function* () {
266− const { executor, scopeId, baseUrl } = yield* makeExecutor();
267− const { calls } = mockTokenFetch(
268− () =>
269− Effect.succeed(
270− new Response(
271− JSON.stringify({
272− access_token: "fresh-access-v2",
273− token_type: "Bearer",
274− refresh_token: "refresh-v2",
275− expires_in: 3600,
276− }),
277− { status: 200, headers: { "content-type": "application/json" } },
278− ),
279− ),
280− );
281−
282− const auth = yield* seedExpiredConnection(
283− executor,
284− scopeId,
285− "conn-refresh-ok",
286− );
287−
288− yield* executor.openapi.addSpec({
289− spec: specJson,
290− scope: scopeId,
291− namespace: "petstore",
292− baseUrl,
293− oauth2: auth,
294− });
295−
296− const result = (yield* executor.tools.invoke(
297− "petstore.items.echoHeaders",
298− {},
299− autoApprove,
300− )) as { data: { authorization?: string } | null; error: unknown };
301−
302− expect(result.error).toBeNull();
303− // Proves the refresh landed: invoke carried the fresh token,
304− // not the expired one we seeded.
305− expect(result.data?.authorization).toBe("Bearer fresh-access-v2");
306− expect(calls).toHaveLength(1);
307− expect(calls[0]!.body.get("grant_type")).toBe("refresh_token");
308− expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1");
309−
310− // Connection row is patched with the new expiry so the next
311− // invoke in-window doesn't trip a second refresh.
312− const conn = yield* executor.connections.get("conn-refresh-ok");
313− expect(conn).not.toBeNull();
314− expect(conn!.expiresAt).not.toBeNull();
315− expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000);
316− }),
257+ it.effect("expired access_token is refreshed via grant_type=refresh_token before invoke", () =>
258+ Effect.gen(function* () {
259+ const { executor, scopeId, baseUrl } = yield* makeExecutor();
260+ const { calls } = mockTokenFetch(() =>
261+ Effect.succeed(
262+ new Response(
263+ JSON.stringify({
264+ access_token: "fresh-access-v2",
265+ token_type: "Bearer",
266+ refresh_token: "refresh-v2",
267+ expires_in: 3600,
268+ }),
269+ { status: 200, headers: { "content-type": "application/json" } },
270+ ),
271+ ),
272+ );
273+
274+ const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-ok");
275+
276+ yield* executor.openapi.addSpec({
277+ spec: specJson,
278+ scope: String(scopeId),
279+ namespace: "petstore",
280+ baseUrl,
281+ oauth2: auth,
282+ });
283+
284+ const result = (yield* executor.tools.invoke(
285+ "petstore.items.echoHeaders",
286+ {},
287+ autoApprove,
288+ )) as { data: { authorization?: string } | null; error: unknown };
289+
290+ expect(result.error).toBeNull();
291+ // Proves the refresh landed: invoke carried the fresh token,
292+ // not the expired one we seeded.
293+ expect(result.data?.authorization).toBe("Bearer fresh-access-v2");
294+ expect(calls).toHaveLength(1);
295+ expect(calls[0]!.body.get("grant_type")).toBe("refresh_token");
296+ expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1");
297+
298+ // Connection row is patched with the new expiry so the next
299+ // invoke in-window doesn't trip a second refresh.
300+ const conn = yield* executor.connections.get("conn-refresh-ok");
301+ expect(conn).not.toBeNull();
302+ expect(conn!.expiresAt).not.toBeNull();
303+ expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000);
304+ }),
317305 );
318306
319− it.effect(
320− "concurrent invokes with an expired token issue exactly one refresh",
321− () =>
322− Effect.gen(function* () {
323− const { executor, scopeId, baseUrl } = yield* makeExecutor();
324− const { calls } = mockTokenFetch(
325− () =>
326− Effect.succeed(
327− new Response(
328− JSON.stringify({
329− access_token: "fresh-access-v2",
330− token_type: "Bearer",
331− refresh_token: "refresh-v2",
332− expires_in: 3600,
333− }),
334− { status: 200, headers: { "content-type": "application/json" } },
335− ),
336− ),
337− );
338−
339− const auth = yield* seedExpiredConnection(
340− executor,
341− scopeId,
342− "conn-refresh-concurrent",
343− );
344−
345− yield* executor.openapi.addSpec({
346− spec: specJson,
347− scope: scopeId,
348− namespace: "petstore",
349− baseUrl,
350− oauth2: auth,
351− });
352−
353− const invokes = yield* Effect.all(
354− [1, 2, 3, 4, 5].map(() =>
355− executor.tools.invoke(
356− "petstore.items.echoHeaders",
357− {},
358− autoApprove,
359− ),
307+ it.effect("concurrent invokes with an expired token issue exactly one refresh", () =>
308+ Effect.gen(function* () {
309+ const { executor, scopeId, baseUrl } = yield* makeExecutor();
310+ const { calls } = mockTokenFetch(() =>
311+ Effect.succeed(
312+ new Response(
313+ JSON.stringify({
314+ access_token: "fresh-access-v2",
315+ token_type: "Bearer",
316+ refresh_token: "refresh-v2",
317+ expires_in: 3600,
318+ }),
319+ { status: 200, headers: { "content-type": "application/json" } },
360320 ),
361− { concurrency: "unbounded" },
362− );
363−
364− for (const r of invokes) {
365− const res = r as {
366− data: { authorization?: string } | null;
367− error: unknown;
368− };
369− expect(res.error).toBeNull();
370− expect(res.data?.authorization).toBe("Bearer fresh-access-v2");
371− }
372− // Critical assertion: the SDK's dedup collapses every parallel
373− // invoke into one call to the token endpoint. Anything more
374− // means we're hammering the AS under load.
375− expect(calls).toHaveLength(1);
376− }),
321+ ),
322+ );
323+
324+ const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-concurrent");
325+
326+ yield* executor.openapi.addSpec({
327+ spec: specJson,
328+ scope: String(scopeId),
329+ namespace: "petstore",
330+ baseUrl,
331+ oauth2: auth,
332+ });
333+
334+ const invokes = yield* Effect.all(
335+ [1, 2, 3, 4, 5].map(() =>
336+ executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
337+ ),
338+ { concurrency: "unbounded" },
339+ );
340+
341+ for (const r of invokes) {
342+ const res = r as {
343+ data: { authorization?: string } | null;
344+ error: unknown;
345+ };
346+ expect(res.error).toBeNull();
347+ expect(res.data?.authorization).toBe("Bearer fresh-access-v2");
348+ }
349+ // Critical assertion: the SDK's dedup collapses every parallel
350+ // invoke into one call to the token endpoint. Anything more
351+ // means we're hammering the AS under load.
352+ expect(calls).toHaveLength(1);
353+ }),
377354 );
378355
379− it.effect(
380− "invalid_grant from refresh surfaces as ConnectionReauthRequiredError",
381− () =>
382− Effect.gen(function* () {
383− const { executor, scopeId, baseUrl } = yield* makeExecutor();
384− mockTokenFetch(
385− () =>
386− Effect.succeed(
387− new Response(
388− JSON.stringify({
389− error: "invalid_grant",
390− error_description: "Refresh token revoked",
391− }),
392− { status: 400, headers: { "content-type": "application/json" } },
393− ),
394− ),
395− );
396−
397− const auth = yield* seedExpiredConnection(
398− executor,
399− scopeId,
400− "conn-refresh-dead",
401− );
402−
403− yield* executor.openapi.addSpec({
404− spec: specJson,
405− scope: scopeId,
406− namespace: "petstore",
407− baseUrl,
408− oauth2: auth,
409− });
410−
411− // Tool invocation currently wraps connection errors in a
412− // generic Error (see openapi invokeTool), so we assert against
413− // the `accessToken` call directly too — that's the surface
414− // the UI bridges use to trigger re-auth.
415− const reauthRequired = yield* executor.connections
416− .accessToken("conn-refresh-dead")
417− .pipe(
418− Effect.flatMap(() =>
419− Effect.fail(
420− new OpenApiOauthTestSetupError({
421− message: "Expected refresh to require re-auth",
422− }),
423− ),
424− ),
425− Effect.catchTag("ConnectionReauthRequiredError", Effect.succeed),
426− );
427− expect(reauthRequired.provider).toBe(
428− "openapi:oauth2",
429− );
430− expect(
431− reauthRequired.message,
432− ).toMatch(/invalid_grant|revoked/i);
433− }),
356+ it.effect("invalid_grant from refresh surfaces as ConnectionReauthRequiredError", () =>
357+ Effect.gen(function* () {
358+ const { executor, scopeId, baseUrl } = yield* makeExecutor();
359+ mockTokenFetch(() =>
360+ Effect.succeed(
361+ new Response(
362+ JSON.stringify({
363+ error: "invalid_grant",
364+ error_description: "Refresh token revoked",
365+ }),
366+ { status: 400, headers: { "content-type": "application/json" } },
367+ ),
368+ ),
369+ );
370+
371+ const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-dead");
372+
373+ yield* executor.openapi.addSpec({
374+ spec: specJson,
375+ scope: String(scopeId),
376+ namespace: "petstore",
377+ baseUrl,
378+ oauth2: auth,
379+ });
380+
381+ // Tool invocation currently wraps connection errors in a
382+ // generic Error (see openapi invokeTool), so we assert against
383+ // the `accessToken` call directly too — that's the surface
384+ // the UI bridges use to trigger re-auth.
385+ const flipped = yield* executor.connections.accessToken("conn-refresh-dead").pipe(
386+ Effect.flip,
387+ Effect.flatMap((error) =>
388+ Predicate.isTagged("ConnectionReauthRequiredError")(error)
389+ ? Effect.succeed(error)
390+ : Effect.fail(error),
391+ ),
392+ );
393+ expect(flipped.provider).toBe("openapi:oauth2");
394+ expect(flipped.message).toBe("OAuth refresh failed");
395+ }),
434396 );
435397 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t