History

Fix core OAuth typed boundaries

Rhys Sullivan committed 6 months ago

Commit 70d01de1 parent b039b6e2 files · +388 −542Browse files

Unified · Split

2 files
  1. packages/core/sdk/src/oauth-discovery.ts +120 −152
  2. packages/core/sdk/src/oauth-service.ts +268 −390
packages/core/sdk/src/oauth-discovery.ts+120−152
@@ -18,7 +18,7 @@ · 17 unmodified lines
1818 // callers actually need.
1919 // ---------------------------------------------------------------------------
2020
21−import { Data, Effect, Result, Schema } from "effect";
21+import { Data, Effect, Option, Predicate, Result, Schema } from "effect";
2222 import * as oauth from "oauth4webapi";
2323
2424 import {
@@ -37,30 +37,23 @@ import { · 12 unmodified lines
3737 * token-endpoint failures. A plugin's refresh path should never have
3838 * to inspect error messages to tell "metadata drifted, re-discover"
3939 * apart from "refresh token is no longer honoured". */
40−export class OAuthDiscoveryError extends Data.TaggedError(
41− "OAuthDiscoveryError",
42−)<{
40+export class OAuthDiscoveryError extends Data.TaggedError("OAuthDiscoveryError")<{
4341 readonly message: string;
4442 readonly status?: number;
4543 readonly cause?: unknown;
4644 }> {}
4745
48−const discoveryError = (
49− message: string,
50− options: { status?: number; cause?: unknown } = {},
51−): OAuthDiscoveryError =>
52− new OAuthDiscoveryError({
53− message,
54− status: options.status,
55− cause: options.cause,
56− });
57−
5846 // ---------------------------------------------------------------------------
5947 // Schemas (narrow structural parsing — the RFCs leave many fields
6048 // optional; we validate only the subset consumers read)
6149 // ---------------------------------------------------------------------------
6250
6351 const StringArray = Schema.Array(Schema.String);
52+const JsonValueSchema = Schema.fromJsonString(Schema.Unknown);
53+const DcrErrorBodySchema = Schema.Struct({
54+ error: Schema.NonEmptyString,
55+ error_description: Schema.optional(Schema.String),
56+});
6457
6558 export const OAuthProtectedResourceMetadataSchema = Schema.Struct({
6659 resource: Schema.optional(Schema.String),
@@ -69,8 +62,7 @@ export const OAuthProtectedResourceMetadataSchema = Schema.Struct({ · 2 unmodified lines
6962 bearer_methods_supported: Schema.optional(StringArray),
7063 resource_documentation: Schema.optional(Schema.String),
7164 }).annotate({ identifier: "OAuthProtectedResourceMetadata" });
72−export type OAuthProtectedResourceMetadata =
73− typeof OAuthProtectedResourceMetadataSchema.Type;
65+export type OAuthProtectedResourceMetadata = typeof OAuthProtectedResourceMetadataSchema.Type;
7466
7567 export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({
7668 issuer: Schema.String,
@@ -87,8 +79,7 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({ · 10 unmodified lines
8779 userinfo_endpoint: Schema.optional(Schema.String),
8880 id_token_signing_alg_values_supported: Schema.optional(StringArray),
8981 }).annotate({ identifier: "OAuthAuthorizationServerMetadata" });
90−export type OAuthAuthorizationServerMetadata =
91− typeof OAuthAuthorizationServerMetadataSchema.Type;
82+export type OAuthAuthorizationServerMetadata = typeof OAuthAuthorizationServerMetadataSchema.Type;
9283
9384 export type DynamicClientMetadata = {
9485 readonly client_name?: string;
@@ -127,15 +118,9 @@ export const OAuthClientInformationSchema = Schema.Struct({ · 32 unmodified lines
127118 }).annotate({ identifier: "OAuthClientInformation" });
128119 export type OAuthClientInformation = typeof OAuthClientInformationSchema.Type;
129120
130−const decodeResourceMetadata = Schema.decodeUnknownEffect(
131− OAuthProtectedResourceMetadataSchema,
132−);
133−const decodeAuthServerMetadata = Schema.decodeUnknownEffect(
134− OAuthAuthorizationServerMetadataSchema,
135−);
136−const decodeClientInformation = Schema.decodeUnknownEffect(
137− OAuthClientInformationSchema,
138−);
121+const decodeResourceMetadata = Schema.decodeUnknownEffect(OAuthProtectedResourceMetadataSchema);
122+const decodeAuthServerMetadata = Schema.decodeUnknownEffect(OAuthAuthorizationServerMetadataSchema);
123+const decodeClientInformation = Schema.decodeUnknownEffect(OAuthClientInformationSchema);
139124
140125 export interface DiscoveryRequestOptions {
141126 /** Injected for tests. Defaults to the global `fetch`. */
@@ -154,23 +139,28 @@ export interface DiscoveryRequestOptions { · 12 unmodified lines
154139
155140 const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
156141
157−const isLoopbackHttpUrl = (value: string): boolean => {
142+const parseUrlOption = (value: string): URL | null => {
143+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser
158144 try {
159− const url = new URL(value);
160− if (url.protocol !== "http:") return false;
161− const hostname = url.hostname.toLowerCase();
162− return (
163− hostname === "localhost" ||
164− hostname === "0.0.0.0" ||
165− hostname === "::1" ||
166− hostname === "[::1]" ||
167− hostname.startsWith("127.")
168− );
145+ return new URL(value);
169146 } catch {
170− return false;
147+ return null;
171148 }
172149 };
173150
151+const isLoopbackHttpUrl = (value: string): boolean => {
152+ const parsed = parseUrlOption(value);
153+ if (!parsed || parsed.protocol !== "http:") return false;
154+ const hostname = parsed.hostname.toLowerCase();
155+ return (
156+ hostname === "localhost" ||
157+ hostname === "0.0.0.0" ||
158+ hostname === "::1" ||
159+ hostname === "[::1]" ||
160+ hostname.startsWith("127.")
161+ );
162+};
163+
174164 const oauth4webapiOptions = (
175165 options: DiscoveryRequestOptions,
176166 targetUrl?: string,
@@ -178,9 +168,7 @@ const oauth4webapiOptions = ( · 1 unmodified line
178168 const out: Record<string, unknown> = {};
179169 if (options.fetch) (out as { [customFetch]?: typeof fetch })[customFetch] = options.fetch;
180170 if (targetUrl && isLoopbackHttpUrl(targetUrl)) {
181− (out as { [oauth.allowInsecureRequests]?: boolean })[
182− oauth.allowInsecureRequests
183− ] = true;
171+ (out as { [oauth.allowInsecureRequests]?: boolean })[oauth.allowInsecureRequests] = true;
184172 }
185173 const signal = AbortSignal.timeout(options.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS);
186174 out.signal = signal;
@@ -231,8 +219,7 @@ export const discoverProtectedResourceMetadata = ( · 44 unmodified lines
231219 resourceUrl: string,
232220 options: DiscoveryRequestOptions = {},
233221 ): Effect.Effect<
234− | { readonly metadataUrl: string; readonly metadata: OAuthProtectedResourceMetadata }
235− | null,
222+ { readonly metadataUrl: string; readonly metadata: OAuthProtectedResourceMetadata } | null,
236223 OAuthDiscoveryError
237224 > =>
238225 Effect.gen(function* () {
@@ -260,30 +247,35 @@ export const discoverProtectedResourceMetadata = ( · 21 unmodified lines
260247 }
261248 const text = await response.text();
262249 if (text.length === 0) return "skip" as const;
263− return { status: response.status, body: JSON.parse(text) } as const;
250+ return { status: response.status, body: text } as const;
264251 },
265252 catch: (cause) =>
266− discoveryError(
267− `Failed to fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}`,
268− { cause },
269− ),
253+ new OAuthDiscoveryError({
254+ message: `Failed to fetch ${url}`,
255+ cause,
256+ }),
270257 });
271258 if (result === "skip") continue;
272259 if (!("body" in result)) {
273− return yield* Effect.fail(
274− discoveryError(
275− `Protected resource metadata returned status ${result.status}`,
276− { status: result.status },
277− ),
278− );
260+ return yield* new OAuthDiscoveryError({
261+ message: `Protected resource metadata returned status ${result.status}`,
262+ status: result.status,
263+ });
279264 }
280− const metadata = yield* decodeResourceMetadata(result.body).pipe(
265+ const parsedBody = yield* Schema.decodeUnknownEffect(JsonValueSchema)(result.body).pipe(
281266 Effect.mapError(
282267 (err) =>
283268 new OAuthDiscoveryError({
284− message: `Protected resource metadata is malformed: ${
285− Schema.isSchemaError(err) ? err.message : String(err)
286− }`,
269+ message: "Protected resource metadata is malformed: invalid JSON",
270+ cause: err,
271+ }),
272+ ),
273+ );
274+ const metadata = yield* decodeResourceMetadata(parsedBody).pipe(
275+ Effect.mapError(
276+ (err) =>
277+ new OAuthDiscoveryError({
278+ message: "Protected resource metadata is malformed: invalid shape",
287279 cause: err,
288280 }),
289281 ),
@@ -308,9 +300,7 @@ const wellKnownUrlFor = ( · 18 unmodified lines
308300 ): string => {
309301 // Mirrors the library's own well-known composition so the URL we
310302 // surface matches what was actually fetched.
311− const suffix = algorithm === "oauth2"
312− ? "oauth-authorization-server"
313− : "openid-configuration";
303+ const suffix = algorithm === "oauth2" ? "oauth-authorization-server" : "openid-configuration";
314304 return issuerPath && issuerPath !== "/"
315305 ? `${issuerOrigin}/.well-known/${suffix}${issuerPath}`
316306 : `${issuerOrigin}/.well-known/${suffix}`;
@@ -320,11 +310,10 @@ export const discoverAuthorizationServerMetadata = ( · 3 unmodified lines
320310 issuer: string,
321311 options: DiscoveryRequestOptions = {},
322312 ): Effect.Effect<
323− | {
324− readonly metadataUrl: string;
325− readonly metadata: OAuthAuthorizationServerMetadata;
326− }
327− | null,
313+ {
314+ readonly metadataUrl: string;
315+ readonly metadata: OAuthAuthorizationServerMetadata;
316+ } | null,
328317 OAuthDiscoveryError
329318 > =>
330319 Effect.gen(function* () {
@@ -349,13 +338,11 @@ export const discoverAuthorizationServerMetadata = ( · 18 unmodified lines
349338 };
350339 },
351340 catch: (cause) => {
352− if (cause instanceof OAuthDiscoveryError) return cause;
353− return discoveryError(
354− `Discovery (${algorithm}) failed for ${issuer}: ${
355− cause instanceof Error ? cause.message : String(cause)
356− }`,
357− { cause },
358− );
341+ if (Predicate.isTagged("OAuthDiscoveryError")(cause)) return cause;
342+ return new OAuthDiscoveryError({
343+ message: `Discovery (${algorithm}) failed for ${issuer}`,
344+ cause,
345+ });
359346 },
360347 }).pipe(
361348 // If one algorithm fails mid-roundtrip (network, parse, issuer
@@ -370,9 +357,7 @@ export const discoverAuthorizationServerMetadata = ( · 8 unmodified lines
370357 Effect.mapError(
371358 (err) =>
372359 new OAuthDiscoveryError({
373− message: `Authorization server metadata is malformed: ${
374− Schema.isSchemaError(err) ? err.message : String(err)
375− }`,
360+ message: "Authorization server metadata is malformed: invalid shape",
376361 cause: err,
377362 }),
378363 ),
@@ -431,29 +416,19 @@ const buildDcrBody = (m: DynamicClientMetadata): Record<string, unknown> => { · 52 unmodified lines
431416 return body;
432417 };
433418
434−const interpretDcrFailure = (
435− status: number,
436− text: string,
437−): DcrErrorBody | DcrTransport => {
419+const interpretDcrFailure = (status: number, text: string): DcrErrorBody | DcrTransport => {
438420 // RFC 6749 error envelope: `{error, error_description?}` with 4xx.
439421 if (status >= 400 && status < 500) {
440− const parsed = Result.try({
441− try: () => (text ? (JSON.parse(text) as unknown) : null),
442− catch: () => null,
443− });
444− const body = Result.isSuccess(parsed) ? parsed.success : null;
445− if (
446− body &&
447− typeof body === "object" &&
448− "error" in body &&
449− typeof body.error === "string" &&
450− body.error.length > 0
451− ) {
452− const desc =
453− "error_description" in body && typeof body.error_description === "string"
454− ? body.error_description
455− : undefined;
456− return new DcrErrorBody({ status, error: body.error, error_description: desc });
422+ const parsedJson = Schema.decodeUnknownOption(JsonValueSchema)(text);
423+ const parsed = Option.isSome(parsedJson)
424+ ? Schema.decodeUnknownOption(DcrErrorBodySchema)(parsedJson.value)
425+ : Option.none();
426+ if (Option.isSome(parsed)) {
427+ return new DcrErrorBody({
428+ status,
429+ error: parsed.value.error,
430+ error_description: parsed.value.error_description,
431+ });
457432 }
458433 }
459434 return new DcrTransport({
@@ -468,10 +443,7 @@ export const registerDynamicClient = ( · 8 unmodified lines
468443 ): Effect.Effect<OAuthClientInformation, OAuthDiscoveryError> =>
469444 Effect.gen(function* () {
470445 const url = new URL(input.registrationEndpoint);
471− if (
472− url.protocol !== "https:" &&
473− !isLoopbackHttpUrl(input.registrationEndpoint)
474− ) {
446+ if (url.protocol !== "https:" && !isLoopbackHttpUrl(input.registrationEndpoint)) {
475447 return yield* new DcrTransport({
476448 message: `registration_endpoint must be HTTPS or a loopback HTTP URL (got ${url.protocol}//${url.host})`,
477449 });
@@ -497,7 +469,7 @@ export const registerDynamicClient = ( · 19 unmodified lines
497469 }),
498470 catch: (cause) =>
499471 new DcrTransport({
500− message: `Dynamic Client Registration request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
472+ message: "Dynamic Client Registration request failed",
501473 cause,
502474 }),
503475 });
@@ -505,9 +477,15 @@ export const registerDynamicClient = ( · 1 unmodified line
505477 // Accept both 200 and 201 as success — RFC 7591 mandates 201, but
506478 // Todoist (and others) return 200 OK with the client information body.
507479 if (response.status !== 200 && response.status !== 201) {
508− const text = yield* Effect.promise(() =>
509− response.text().catch(() => ""),
510− );
480+ const text = yield* Effect.tryPromise({
481+ try: () => response.text(),
482+ catch: (cause) =>
483+ new DcrTransport({
484+ message: "Dynamic Client Registration error response could not be read",
485+ status: response.status,
486+ cause,
487+ }),
488+ }).pipe(Effect.catchTag("DcrTransport", () => Effect.succeed("")));
511489 return yield* interpretDcrFailure(response.status, text);
512490 }
513491
@@ -520,22 +498,21 @@ export const registerDynamicClient = ( · 6 unmodified lines
520498 cause,
521499 }),
522500 });
523− const json = yield* Effect.try({
524− try: () => JSON.parse(text) as unknown,
525− catch: (cause) =>
526− new DcrTransport({
527− message: "Dynamic Client Registration response was not valid JSON",
528− status: response.status,
529− cause,
530− }),
531− });
501+ const json = yield* Schema.decodeUnknownEffect(JsonValueSchema)(text).pipe(
502+ Effect.mapError(
503+ (cause) =>
504+ new DcrTransport({
505+ message: "Dynamic Client Registration response was not valid JSON",
506+ status: response.status,
507+ cause,
508+ }),
509+ ),
510+ );
532511 return yield* decodeClientInformation(json).pipe(
533512 Effect.mapError(
534513 (err) =>
535514 new OAuthDiscoveryError({
536− message: `Dynamic Client Registration response is malformed: ${
537− Schema.isSchemaError(err) ? err.message : String(err)
538− }`,
515+ message: "Dynamic Client Registration response is malformed: invalid shape",
539516 cause: err,
540517 }),
541518 ),
@@ -544,16 +521,18 @@ export const registerDynamicClient = ( · 2 unmodified lines
544521 Effect.catchTags({
545522 DcrErrorBody: (err) =>
546523 Effect.fail(
547− discoveryError(
548− `Dynamic Client Registration failed: ${err.error}${
524+ new OAuthDiscoveryError({
525+ message: `Dynamic Client Registration failed: ${err.error}${
549526 err.error_description ? ` — ${err.error_description}` : ""
550527 }`,
551− { status: err.status, cause: err },
552− ),
528+ status: err.status,
529+ cause: err,
530+ }),
553531 ),
554532 DcrTransport: (err) =>
555533 Effect.fail(
556− discoveryError(`Dynamic Client Registration failed: ${err.message}`, {
534+ new OAuthDiscoveryError({
535+ message: "Dynamic Client Registration failed",
557536 status: err.status,
558537 cause: err.cause ?? err,
559538 }),
@@ -630,11 +609,10 @@ export const beginDynamicAuthorization = ( · 70 unmodified lines
630609
631610 const authorizationServerUrl = (() => {
632611 if (prior.authorizationServerUrl) return prior.authorizationServerUrl;
633− const fromResource =
634− resource && resource.metadata.authorization_servers?.[0];
612+ const fromResource = resource && resource.metadata.authorization_servers?.[0];
635613 if (fromResource) return fromResource;
636− const u = new URL(input.endpoint);
637− return `${u.protocol}//${u.host}`;
614+ const u = parseUrlOption(input.endpoint);
615+ return u ? `${u.protocol}//${u.host}` : input.endpoint;
638616 })();
639617
640618 const authServer =
@@ -643,35 +621,26 @@ export const beginDynamicAuthorization = ( · 2 unmodified lines
643621 metadata: prior.authorizationServerMetadata,
644622 metadataUrl: prior.authorizationServerMetadataUrl,
645623 }
646− : yield* discoverAuthorizationServerMetadata(
647− authorizationServerUrl,
648− options,
649− );
624+ : yield* discoverAuthorizationServerMetadata(authorizationServerUrl, options);
650625
651626 if (!authServer) {
652− return yield* Effect.fail(
653− discoveryError(
654− `No OAuth authorization server metadata at ${authorizationServerUrl}`,
655− ),
656− );
627+ return yield* new OAuthDiscoveryError({
628+ message: `No OAuth authorization server metadata at ${authorizationServerUrl}`,
629+ });
657630 }
658631
659632 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];
660633 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {
661− return yield* Effect.fail(
662− discoveryError(
663− `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,
664− ),
665− );
634+ return yield* new OAuthDiscoveryError({
635+ message: `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,
636+ });
666637 }
667638
668639 const responseTypes = authServer.metadata.response_types_supported ?? [];
669640 if (responseTypes.length > 0 && !responseTypes.includes("code")) {
670− return yield* Effect.fail(
671− discoveryError(
672− `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,
673− ),
674− );
641+ return yield* new OAuthDiscoveryError({
642+ message: `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,
643+ });
675644 }
676645
677646 const baseClientMetadata: DynamicClientMetadata = {
@@ -689,9 +658,10 @@ export const beginDynamicAuthorization = ( · 11 unmodified lines
689658 const reg = authServer.metadata.registration_endpoint;
690659 if (!reg) {
691660 return Effect.fail(
692− discoveryError(
693− "Authorization server does not advertise registration_endpoint — cannot auto-register a client",
694− ),
661+ new OAuthDiscoveryError({
662+ message:
663+ "Authorization server does not advertise registration_endpoint — cannot auto-register a client",
664+ }),
695665 );
696666 }
697667 return registerDynamicClient(
@@ -701,9 +671,7 @@ export const beginDynamicAuthorization = ( · 3 unmodified lines
701671 })());
702672
703673 const codeVerifier = createPkceCodeVerifier();
704− const codeChallenge = yield* Effect.promise(() =>
705− createPkceCodeChallenge(codeVerifier),
706− );
674+ const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
707675 const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? [];
708676
709677 const authorizationUrl = buildAuthorizationUrl({
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 };