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
18// callers actually need.18// callers actually need.
19// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
20 20
21import { Data, Effect, Result, Schema } from "effect";21import { Data, Effect, Option, Predicate, Result, Schema } from "effect";
22import * as oauth from "oauth4webapi";22import * as oauth from "oauth4webapi";
23 23
24import {24import {
@@ -37,30 +37,23 @@ import { · 12 unmodified lines
37 * token-endpoint failures. A plugin's refresh path should never have37 * token-endpoint failures. A plugin's refresh path should never have
38 * to inspect error messages to tell "metadata drifted, re-discover"38 * to inspect error messages to tell "metadata drifted, re-discover"
39 * apart from "refresh token is no longer honoured". */39 * apart from "refresh token is no longer honoured". */
40export class OAuthDiscoveryError extends Data.TaggedError(40export class OAuthDiscoveryError extends Data.TaggedError("OAuthDiscoveryError")<{
41 "OAuthDiscoveryError",
42)<{
43 readonly message: string;41 readonly message: string;
44 readonly status?: number;42 readonly status?: number;
45 readonly cause?: unknown;43 readonly cause?: unknown;
46}> {}44}> {}
47 45
48const 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
58// ---------------------------------------------------------------------------46// ---------------------------------------------------------------------------
59// Schemas (narrow structural parsing — the RFCs leave many fields47// Schemas (narrow structural parsing — the RFCs leave many fields
60// optional; we validate only the subset consumers read)48// optional; we validate only the subset consumers read)
61// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
62 50
63const StringArray = Schema.Array(Schema.String);51const StringArray = Schema.Array(Schema.String);
52const JsonValueSchema = Schema.fromJsonString(Schema.Unknown);
53const DcrErrorBodySchema = Schema.Struct({
54 error: Schema.NonEmptyString,
55 error_description: Schema.optional(Schema.String),
56});
64 57
65export const OAuthProtectedResourceMetadataSchema = Schema.Struct({58export const OAuthProtectedResourceMetadataSchema = Schema.Struct({
66 resource: Schema.optional(Schema.String),59 resource: Schema.optional(Schema.String),
@@ -69,8 +62,7 @@ export const OAuthProtectedResourceMetadataSchema = Schema.Struct({ · 2 unmodified lines
69 bearer_methods_supported: Schema.optional(StringArray),62 bearer_methods_supported: Schema.optional(StringArray),
70 resource_documentation: Schema.optional(Schema.String),63 resource_documentation: Schema.optional(Schema.String),
71}).annotate({ identifier: "OAuthProtectedResourceMetadata" });64}).annotate({ identifier: "OAuthProtectedResourceMetadata" });
72export type OAuthProtectedResourceMetadata =65export type OAuthProtectedResourceMetadata = typeof OAuthProtectedResourceMetadataSchema.Type;
73 typeof OAuthProtectedResourceMetadataSchema.Type;
74 66
75export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({67export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({
76 issuer: Schema.String,68 issuer: Schema.String,
@@ -87,8 +79,7 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({ · 10 unmodified lines
87 userinfo_endpoint: Schema.optional(Schema.String),79 userinfo_endpoint: Schema.optional(Schema.String),
88 id_token_signing_alg_values_supported: Schema.optional(StringArray),80 id_token_signing_alg_values_supported: Schema.optional(StringArray),
89}).annotate({ identifier: "OAuthAuthorizationServerMetadata" });81}).annotate({ identifier: "OAuthAuthorizationServerMetadata" });
90export type OAuthAuthorizationServerMetadata =82export type OAuthAuthorizationServerMetadata = typeof OAuthAuthorizationServerMetadataSchema.Type;
91 typeof OAuthAuthorizationServerMetadataSchema.Type;
92 83
93export type DynamicClientMetadata = {84export type DynamicClientMetadata = {
94 readonly client_name?: string;85 readonly client_name?: string;
@@ -127,15 +118,9 @@ export const OAuthClientInformationSchema = Schema.Struct({ · 32 unmodified lines
127}).annotate({ identifier: "OAuthClientInformation" });118}).annotate({ identifier: "OAuthClientInformation" });
128export type OAuthClientInformation = typeof OAuthClientInformationSchema.Type;119export type OAuthClientInformation = typeof OAuthClientInformationSchema.Type;
129 120
130const decodeResourceMetadata = Schema.decodeUnknownEffect(121const decodeResourceMetadata = Schema.decodeUnknownEffect(OAuthProtectedResourceMetadataSchema);
131 OAuthProtectedResourceMetadataSchema,122const decodeAuthServerMetadata = Schema.decodeUnknownEffect(OAuthAuthorizationServerMetadataSchema);
132);123const decodeClientInformation = Schema.decodeUnknownEffect(OAuthClientInformationSchema);
133const decodeAuthServerMetadata = Schema.decodeUnknownEffect(
134 OAuthAuthorizationServerMetadataSchema,
135);
136const decodeClientInformation = Schema.decodeUnknownEffect(
137 OAuthClientInformationSchema,
138);
139 124
140export interface DiscoveryRequestOptions {125export interface DiscoveryRequestOptions {
141 /** Injected for tests. Defaults to the global `fetch`. */126 /** Injected for tests. Defaults to the global `fetch`. */
@@ -154,23 +139,28 @@ export interface DiscoveryRequestOptions { · 12 unmodified lines
154 139
155const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";140const MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
156 141
157const isLoopbackHttpUrl = (value: string): boolean => {142const parseUrlOption = (value: string): URL | null => {
143 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser
158 try {144 try {
159 const url = new URL(value);145 return 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 );
169 } catch {146 } catch {
170 return false;147 return null;
171 }148 }
172};149};
173 150
151const 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
174const oauth4webapiOptions = (164const oauth4webapiOptions = (
175 options: DiscoveryRequestOptions,165 options: DiscoveryRequestOptions,
176 targetUrl?: string,166 targetUrl?: string,
@@ -178,9 +168,7 @@ const oauth4webapiOptions = ( · 1 unmodified line
178 const out: Record<string, unknown> = {};168 const out: Record<string, unknown> = {};
179 if (options.fetch) (out as { [customFetch]?: typeof fetch })[customFetch] = options.fetch;169 if (options.fetch) (out as { [customFetch]?: typeof fetch })[customFetch] = options.fetch;
180 if (targetUrl && isLoopbackHttpUrl(targetUrl)) {170 if (targetUrl && isLoopbackHttpUrl(targetUrl)) {
181 (out as { [oauth.allowInsecureRequests]?: boolean })[171 (out as { [oauth.allowInsecureRequests]?: boolean })[oauth.allowInsecureRequests] = true;
182 oauth.allowInsecureRequests
183 ] = true;
184 }172 }
185 const signal = AbortSignal.timeout(options.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS);173 const signal = AbortSignal.timeout(options.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS);
186 out.signal = signal;174 out.signal = signal;
@@ -231,8 +219,7 @@ export const discoverProtectedResourceMetadata = ( · 44 unmodified lines
231 resourceUrl: string,219 resourceUrl: string,
232 options: DiscoveryRequestOptions = {},220 options: DiscoveryRequestOptions = {},
233): Effect.Effect<221): Effect.Effect<
234 | { readonly metadataUrl: string; readonly metadata: OAuthProtectedResourceMetadata }222 { readonly metadataUrl: string; readonly metadata: OAuthProtectedResourceMetadata } | null,
235 | null,
236 OAuthDiscoveryError223 OAuthDiscoveryError
237> =>224> =>
238 Effect.gen(function* () {225 Effect.gen(function* () {
@@ -260,30 +247,35 @@ export const discoverProtectedResourceMetadata = ( · 21 unmodified lines
260 }247 }
261 const text = await response.text();248 const text = await response.text();
262 if (text.length === 0) return "skip" as const;249 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;
264 },251 },
265 catch: (cause) =>252 catch: (cause) =>
266 discoveryError(253 new OAuthDiscoveryError({
267 `Failed to fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}`,254 message: `Failed to fetch ${url}`,
268 { cause },255 cause,
269 ),256 }),
270 });257 });
271 if (result === "skip") continue;258 if (result === "skip") continue;
272 if (!("body" in result)) {259 if (!("body" in result)) {
273 return yield* Effect.fail(260 return yield* new OAuthDiscoveryError({
274 discoveryError(261 message: `Protected resource metadata returned status ${result.status}`,
275 `Protected resource metadata returned status ${result.status}`,262 status: result.status,
276 { status: result.status },263 });
277 ),
278 );
279 }264 }
280 const metadata = yield* decodeResourceMetadata(result.body).pipe(265 const parsedBody = yield* Schema.decodeUnknownEffect(JsonValueSchema)(result.body).pipe(
281 Effect.mapError(266 Effect.mapError(
282 (err) =>267 (err) =>
283 new OAuthDiscoveryError({268 new OAuthDiscoveryError({
284 message: `Protected resource metadata is malformed: ${269 message: "Protected resource metadata is malformed: invalid JSON",
285 Schema.isSchemaError(err) ? err.message : String(err)270 cause: err,
286 }`,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",
287 cause: err,279 cause: err,
288 }),280 }),
289 ),281 ),
@@ -308,9 +300,7 @@ const wellKnownUrlFor = ( · 18 unmodified lines
308): string => {300): string => {
309 // Mirrors the library's own well-known composition so the URL we301 // Mirrors the library's own well-known composition so the URL we
310 // surface matches what was actually fetched.302 // surface matches what was actually fetched.
311 const suffix = algorithm === "oauth2"303 const suffix = algorithm === "oauth2" ? "oauth-authorization-server" : "openid-configuration";
312 ? "oauth-authorization-server"
313 : "openid-configuration";
314 return issuerPath && issuerPath !== "/"304 return issuerPath && issuerPath !== "/"
315 ? `${issuerOrigin}/.well-known/${suffix}${issuerPath}`305 ? `${issuerOrigin}/.well-known/${suffix}${issuerPath}`
316 : `${issuerOrigin}/.well-known/${suffix}`;306 : `${issuerOrigin}/.well-known/${suffix}`;
@@ -320,11 +310,10 @@ export const discoverAuthorizationServerMetadata = ( · 3 unmodified lines
320 issuer: string,310 issuer: string,
321 options: DiscoveryRequestOptions = {},311 options: DiscoveryRequestOptions = {},
322): Effect.Effect<312): Effect.Effect<
323 | {313 {
324 readonly metadataUrl: string;314 readonly metadataUrl: string;
325 readonly metadata: OAuthAuthorizationServerMetadata;315 readonly metadata: OAuthAuthorizationServerMetadata;
326 }316 } | null,
327 | null,
328 OAuthDiscoveryError317 OAuthDiscoveryError
329> =>318> =>
330 Effect.gen(function* () {319 Effect.gen(function* () {
@@ -349,13 +338,11 @@ export const discoverAuthorizationServerMetadata = ( · 18 unmodified lines
349 };338 };
350 },339 },
351 catch: (cause) => {340 catch: (cause) => {
352 if (cause instanceof OAuthDiscoveryError) return cause;341 if (Predicate.isTagged("OAuthDiscoveryError")(cause)) return cause;
353 return discoveryError(342 return new OAuthDiscoveryError({
354 `Discovery (${algorithm}) failed for ${issuer}: ${343 message: `Discovery (${algorithm}) failed for ${issuer}`,
355 cause instanceof Error ? cause.message : String(cause)344 cause,
356 }`,345 });
357 { cause },
358 );
359 },346 },
360 }).pipe(347 }).pipe(
361 // If one algorithm fails mid-roundtrip (network, parse, issuer348 // If one algorithm fails mid-roundtrip (network, parse, issuer
@@ -370,9 +357,7 @@ export const discoverAuthorizationServerMetadata = ( · 8 unmodified lines
370 Effect.mapError(357 Effect.mapError(
371 (err) =>358 (err) =>
372 new OAuthDiscoveryError({359 new OAuthDiscoveryError({
373 message: `Authorization server metadata is malformed: ${360 message: "Authorization server metadata is malformed: invalid shape",
374 Schema.isSchemaError(err) ? err.message : String(err)
375 }`,
376 cause: err,361 cause: err,
377 }),362 }),
378 ),363 ),
@@ -431,29 +416,19 @@ const buildDcrBody = (m: DynamicClientMetadata): Record<string, unknown> => { · 52 unmodified lines
431 return body;416 return body;
432};417};
433 418
434const interpretDcrFailure = (419const interpretDcrFailure = (status: number, text: string): DcrErrorBody | DcrTransport => {
435 status: number,
436 text: string,
437): DcrErrorBody | DcrTransport => {
438 // RFC 6749 error envelope: `{error, error_description?}` with 4xx.420 // RFC 6749 error envelope: `{error, error_description?}` with 4xx.
439 if (status >= 400 && status < 500) {421 if (status >= 400 && status < 500) {
440 const parsed = Result.try({422 const parsedJson = Schema.decodeUnknownOption(JsonValueSchema)(text);
441 try: () => (text ? (JSON.parse(text) as unknown) : null),423 const parsed = Option.isSome(parsedJson)
442 catch: () => null,424 ? Schema.decodeUnknownOption(DcrErrorBodySchema)(parsedJson.value)
443 });425 : Option.none();
444 const body = Result.isSuccess(parsed) ? parsed.success : null;426 if (Option.isSome(parsed)) {
445 if (427 return new DcrErrorBody({
446 body &&428 status,
447 typeof body === "object" &&429 error: parsed.value.error,
448 "error" in body &&430 error_description: parsed.value.error_description,
449 typeof body.error === "string" &&431 });
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 });
457 }432 }
458 }433 }
459 return new DcrTransport({434 return new DcrTransport({
@@ -468,10 +443,7 @@ export const registerDynamicClient = ( · 8 unmodified lines
468): Effect.Effect<OAuthClientInformation, OAuthDiscoveryError> =>443): Effect.Effect<OAuthClientInformation, OAuthDiscoveryError> =>
469 Effect.gen(function* () {444 Effect.gen(function* () {
470 const url = new URL(input.registrationEndpoint);445 const url = new URL(input.registrationEndpoint);
471 if (446 if (url.protocol !== "https:" && !isLoopbackHttpUrl(input.registrationEndpoint)) {
472 url.protocol !== "https:" &&
473 !isLoopbackHttpUrl(input.registrationEndpoint)
474 ) {
475 return yield* new DcrTransport({447 return yield* new DcrTransport({
476 message: `registration_endpoint must be HTTPS or a loopback HTTP URL (got ${url.protocol}//${url.host})`,448 message: `registration_endpoint must be HTTPS or a loopback HTTP URL (got ${url.protocol}//${url.host})`,
477 });449 });
@@ -497,7 +469,7 @@ export const registerDynamicClient = ( · 19 unmodified lines
497 }),469 }),
498 catch: (cause) =>470 catch: (cause) =>
499 new DcrTransport({471 new DcrTransport({
500 message: `Dynamic Client Registration request failed: ${cause instanceof Error ? cause.message : String(cause)}`,472 message: "Dynamic Client Registration request failed",
501 cause,473 cause,
502 }),474 }),
503 });475 });
@@ -505,9 +477,15 @@ export const registerDynamicClient = ( · 1 unmodified line
505 // Accept both 200 and 201 as success — RFC 7591 mandates 201, but477 // Accept both 200 and 201 as success — RFC 7591 mandates 201, but
506 // Todoist (and others) return 200 OK with the client information body.478 // Todoist (and others) return 200 OK with the client information body.
507 if (response.status !== 200 && response.status !== 201) {479 if (response.status !== 200 && response.status !== 201) {
508 const text = yield* Effect.promise(() =>480 const text = yield* Effect.tryPromise({
509 response.text().catch(() => ""),481 try: () => response.text(),
510 );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("")));
511 return yield* interpretDcrFailure(response.status, text);489 return yield* interpretDcrFailure(response.status, text);
512 }490 }
513 491
@@ -520,22 +498,21 @@ export const registerDynamicClient = ( · 6 unmodified lines
520 cause,498 cause,
521 }),499 }),
522 });500 });
523 const json = yield* Effect.try({501 const json = yield* Schema.decodeUnknownEffect(JsonValueSchema)(text).pipe(
524 try: () => JSON.parse(text) as unknown,502 Effect.mapError(
525 catch: (cause) =>503 (cause) =>
526 new DcrTransport({504 new DcrTransport({
527 message: "Dynamic Client Registration response was not valid JSON",505 message: "Dynamic Client Registration response was not valid JSON",
528 status: response.status,506 status: response.status,
529 cause,507 cause,
530 }),508 }),
531 });509 ),
510 );
532 return yield* decodeClientInformation(json).pipe(511 return yield* decodeClientInformation(json).pipe(
533 Effect.mapError(512 Effect.mapError(
534 (err) =>513 (err) =>
535 new OAuthDiscoveryError({514 new OAuthDiscoveryError({
536 message: `Dynamic Client Registration response is malformed: ${515 message: "Dynamic Client Registration response is malformed: invalid shape",
537 Schema.isSchemaError(err) ? err.message : String(err)
538 }`,
539 cause: err,516 cause: err,
540 }),517 }),
541 ),518 ),
@@ -544,16 +521,18 @@ export const registerDynamicClient = ( · 2 unmodified lines
544 Effect.catchTags({521 Effect.catchTags({
545 DcrErrorBody: (err) =>522 DcrErrorBody: (err) =>
546 Effect.fail(523 Effect.fail(
547 discoveryError(524 new OAuthDiscoveryError({
548 `Dynamic Client Registration failed: ${err.error}${525 message: `Dynamic Client Registration failed: ${err.error}${
549 err.error_description ? ` — ${err.error_description}` : ""526 err.error_description ? ` — ${err.error_description}` : ""
550 }`,527 }`,
551 { status: err.status, cause: err },528 status: err.status,
552 ),529 cause: err,
530 }),
553 ),531 ),
554 DcrTransport: (err) =>532 DcrTransport: (err) =>
555 Effect.fail(533 Effect.fail(
556 discoveryError(`Dynamic Client Registration failed: ${err.message}`, {534 new OAuthDiscoveryError({
535 message: "Dynamic Client Registration failed",
557 status: err.status,536 status: err.status,
558 cause: err.cause ?? err,537 cause: err.cause ?? err,
559 }),538 }),
@@ -630,11 +609,10 @@ export const beginDynamicAuthorization = ( · 70 unmodified lines
630 609
631 const authorizationServerUrl = (() => {610 const authorizationServerUrl = (() => {
632 if (prior.authorizationServerUrl) return prior.authorizationServerUrl;611 if (prior.authorizationServerUrl) return prior.authorizationServerUrl;
633 const fromResource =612 const fromResource = resource && resource.metadata.authorization_servers?.[0];
634 resource && resource.metadata.authorization_servers?.[0];
635 if (fromResource) return fromResource;613 if (fromResource) return fromResource;
636 const u = new URL(input.endpoint);614 const u = parseUrlOption(input.endpoint);
637 return `${u.protocol}//${u.host}`;615 return u ? `${u.protocol}//${u.host}` : input.endpoint;
638 })();616 })();
639 617
640 const authServer =618 const authServer =
@@ -643,35 +621,26 @@ export const beginDynamicAuthorization = ( · 2 unmodified lines
643 metadata: prior.authorizationServerMetadata,621 metadata: prior.authorizationServerMetadata,
644 metadataUrl: prior.authorizationServerMetadataUrl,622 metadataUrl: prior.authorizationServerMetadataUrl,
645 }623 }
646 : yield* discoverAuthorizationServerMetadata(624 : yield* discoverAuthorizationServerMetadata(authorizationServerUrl, options);
647 authorizationServerUrl,
648 options,
649 );
650 625
651 if (!authServer) {626 if (!authServer) {
652 return yield* Effect.fail(627 return yield* new OAuthDiscoveryError({
653 discoveryError(628 message: `No OAuth authorization server metadata at ${authorizationServerUrl}`,
654 `No OAuth authorization server metadata at ${authorizationServerUrl}`,629 });
655 ),
656 );
657 }630 }
658 631
659 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];632 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];
660 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {633 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {
661 return yield* Effect.fail(634 return yield* new OAuthDiscoveryError({
662 discoveryError(635 message: `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,
663 `Authorization server does not support PKCE S256 (advertised: ${pkceMethods.join(", ")})`,636 });
664 ),
665 );
666 }637 }
667 638
668 const responseTypes = authServer.metadata.response_types_supported ?? [];639 const responseTypes = authServer.metadata.response_types_supported ?? [];
669 if (responseTypes.length > 0 && !responseTypes.includes("code")) {640 if (responseTypes.length > 0 && !responseTypes.includes("code")) {
670 return yield* Effect.fail(641 return yield* new OAuthDiscoveryError({
671 discoveryError(642 message: `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,
672 `Authorization server does not support response_type=code (advertised: ${responseTypes.join(", ")})`,643 });
673 ),
674 );
675 }644 }
676 645
677 const baseClientMetadata: DynamicClientMetadata = {646 const baseClientMetadata: DynamicClientMetadata = {
@@ -689,9 +658,10 @@ export const beginDynamicAuthorization = ( · 11 unmodified lines
689 const reg = authServer.metadata.registration_endpoint;658 const reg = authServer.metadata.registration_endpoint;
690 if (!reg) {659 if (!reg) {
691 return Effect.fail(660 return Effect.fail(
692 discoveryError(661 new OAuthDiscoveryError({
693 "Authorization server does not advertise registration_endpoint — cannot auto-register a client",662 message:
694 ),663 "Authorization server does not advertise registration_endpoint — cannot auto-register a client",
664 }),
695 );665 );
696 }666 }
697 return registerDynamicClient(667 return registerDynamicClient(
@@ -701,9 +671,7 @@ export const beginDynamicAuthorization = ( · 3 unmodified lines
701 })());671 })());
702 672
703 const codeVerifier = createPkceCodeVerifier();673 const codeVerifier = createPkceCodeVerifier();
704 const codeChallenge = yield* Effect.promise(() =>674 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
705 createPkceCodeChallenge(codeVerifier),
706 );
707 const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? [];675 const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? [];
708 676
709 const authorizationUrl = buildAuthorizationUrl({677 const authorizationUrl = buildAuthorizationUrl({
packages/core/sdk/src/oauth-service.ts+268−390
@@ -35,13 +35,9 @@ · 34 unmodified lines
35// every strategy because refresh semantics are strategy-independent.35// every strategy because refresh semantics are strategy-independent.
36// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
37 37
38import { Effect, Schema } from "effect";38import { Effect, Option, Predicate, Schema } from "effect";
39 39
40import type {40import type { DBAdapter, StorageFailure, TypedAdapter } from "@executor-js/storage-core";
41 DBAdapter,
42 StorageFailure,
43 TypedAdapter,
44} from "@executor-js/storage-core";
45 41
46import {42import {
47 ConnectionRefreshError,43 ConnectionRefreshError,
@@ -52,9 +48,7 @@ import { · 4 unmodified lines
52 type ConnectionRefreshResult,48 type ConnectionRefreshResult,
53 type ConnectionRef,49 type ConnectionRef,
54} from "./connections";50} from "./connections";
55import type {51import type { ConnectionProviderNotRegisteredError } from "./errors";
56 ConnectionProviderNotRegisteredError,
57} from "./errors";
58import type { CoreSchema } from "./core-schema";52import type { CoreSchema } from "./core-schema";
59import { ConnectionId, ScopeId, SecretId } from "./ids";53import { ConnectionId, ScopeId, SecretId } from "./ids";
60import { SetSecretInput, type SecretRef } from "./secrets";54import { SetSecretInput, type SecretRef } from "./secrets";
@@ -100,6 +94,8 @@ import { · 39 unmodified lines
100 94
101const OAuthAuthorizationServerMetadataJson = Schema.Record(Schema.String, Schema.Unknown);95const OAuthAuthorizationServerMetadataJson = Schema.Record(Schema.String, Schema.Unknown);
102const OAuthClientInformationJson = Schema.Record(Schema.String, Schema.Unknown);96const OAuthClientInformationJson = Schema.Record(Schema.String, Schema.Unknown);
97const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
98const JsonValueSchema = Schema.fromJsonString(Schema.Unknown);
103 99
104const DynamicDcrSessionPayload = Schema.Struct({100const DynamicDcrSessionPayload = Schema.Struct({
105 kind: Schema.Literal("dynamic-dcr"),101 kind: Schema.Literal("dynamic-dcr"),
@@ -110,9 +106,7 @@ const DynamicDcrSessionPayload = Schema.Struct({ · 4 unmodified lines
110 authorizationServerMetadata: OAuthAuthorizationServerMetadataJson,106 authorizationServerMetadata: OAuthAuthorizationServerMetadataJson,
111 clientInformation: OAuthClientInformationJson,107 clientInformation: OAuthClientInformationJson,
112 resourceMetadataUrl: Schema.NullOr(Schema.String),108 resourceMetadataUrl: Schema.NullOr(Schema.String),
113 resourceMetadata: Schema.NullOr(109 resourceMetadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)),
114 Schema.Record(Schema.String, Schema.Unknown),
115 ),
116 scopes: Schema.Array(Schema.String),110 scopes: Schema.Array(Schema.String),
117});111});
118 112
@@ -122,7 +116,9 @@ const AuthorizationCodeSessionPayload = Schema.Struct({ · 3 unmodified lines
122 codeVerifier: Schema.String,116 codeVerifier: Schema.String,
123 authorizationEndpoint: Schema.String,117 authorizationEndpoint: Schema.String,
124 tokenEndpoint: Schema.String,118 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 ),
126 clientIdSecretId: Schema.String,122 clientIdSecretId: Schema.String,
127 clientSecretSecretId: Schema.NullOr(Schema.String),123 clientSecretSecretId: Schema.NullOr(Schema.String),
128 scopes: Schema.Array(Schema.String),124 scopes: Schema.Array(Schema.String),
@@ -144,22 +140,22 @@ const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload); · 15 unmodified lines
144 140
145const coerceJson = (value: unknown): unknown => {141const coerceJson = (value: unknown): unknown => {
146 if (typeof value !== "string") return value;142 if (typeof value !== "string") return value;
147 try {143 const parsed = Schema.decodeUnknownOption(JsonValueSchema)(value);
148 return JSON.parse(value);144 return Option.isSome(parsed) ? parsed.value : value;
149 } catch {
150 return value;
151 }
152};145};
153 146
154const stringArray = (value: unknown): readonly string[] =>147const stringArray = (value: unknown): readonly string[] =>
155 Array.isArray(value)148 Array.isArray(value) ? value.filter((scope): scope is string => typeof scope === "string") : [];
156 ? value.filter((scope): scope is string => typeof scope === "string")
157 : [];
158 149
159const originOrNull = (value: unknown): string | null => {150const originOrNull = (value: unknown): string | null => {
160 if (typeof value !== "string") return null;151 if (typeof value !== "string") return null;
152 return parseUrlOption(value)?.origin ?? null;
153};
154
155const parseUrlOption = (value: string): URL | null => {
156 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser
161 try {157 try {
162 return new URL(value).origin;158 return new URL(value);
163 } catch {159 } catch {
164 return null;160 return null;
165 }161 }
@@ -167,8 +163,8 @@ const originOrNull = (value: unknown): string | null => { · 1 unmodified line
167 163
168const decodeProviderState = (value: unknown): OAuthProviderState => {164const decodeProviderState = (value: unknown): OAuthProviderState => {
169 const raw = coerceJson(value);165 const raw = coerceJson(value);
170 const record =166 const decodedRecord = Schema.decodeUnknownOption(UnknownRecord)(raw);
171 raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;167 const record = Option.isSome(decodedRecord) ? decodedRecord.value : null;
172 168
173 if (record && !("kind" in record) && "flow" in record && "tokenUrl" in record) {169 if (record && !("kind" in record) && "flow" in record && "tokenUrl" in record) {
174 const flow = record.flow;170 const flow = record.flow;
@@ -196,12 +192,7 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 21 unmodified lines
196 }192 }
197 }193 }
198 194
199 if (195 if (record && !("kind" in record) && "clientIdSecretId" in record && "scopes" in record) {
200 record &&
201 !("kind" in record) &&
202 "clientIdSecretId" in record &&
203 "scopes" in record
204 ) {
205 const scopes = stringArray(record.scopes);196 const scopes = stringArray(record.scopes);
206 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({197 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({
207 kind: "authorization-code",198 kind: "authorization-code",
@@ -214,48 +205,38 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 6 unmodified lines
214 });205 });
215 }206 }
216 207
217 if (208 if (record && !("kind" in record) && "clientInformation" in record && "endpoint" in record) {
218 record &&209 const decodedClientInformation = Schema.decodeUnknownOption(UnknownRecord)(
219 !("kind" in record) &&210 record.clientInformation,
220 "clientInformation" in record &&211 );
221 "endpoint" in record212 const clientInformation = Option.isSome(decodedClientInformation)
222 ) {213 ? decodedClientInformation.value
223 const clientInformation =214 : null;
224 record.clientInformation && typeof record.clientInformation === "object"215 const decodedAuthorizationServerMetadata = Schema.decodeUnknownOption(UnknownRecord)(
225 ? (record.clientInformation as Record<string, unknown>)216 record.authorizationServerMetadata,
226 : null;217 );
218 const authorizationServerMetadata = Option.isSome(decodedAuthorizationServerMetadata)
219 ? decodedAuthorizationServerMetadata.value
220 : null;
227 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({221 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({
228 kind: "dynamic-dcr",222 kind: "dynamic-dcr",
229 tokenEndpoint:223 tokenEndpoint:
230 typeof record.tokenEndpoint === "string"224 typeof record.tokenEndpoint === "string"
231 ? record.tokenEndpoint225 ? record.tokenEndpoint
232 : record.authorizationServerMetadata &&226 : typeof authorizationServerMetadata?.token_endpoint === "string"
233 typeof record.authorizationServerMetadata === "object" &&227 ? authorizationServerMetadata.token_endpoint
234 typeof (record.authorizationServerMetadata as Record<string, unknown>)
235 .token_endpoint === "string"
236 ? ((record.authorizationServerMetadata as Record<string, unknown>)
237 .token_endpoint as string)
238 : "",228 : "",
239 issuerUrl:229 issuerUrl:
240 record.authorizationServerMetadata &&230 typeof authorizationServerMetadata?.issuer === "string"
241 typeof record.authorizationServerMetadata === "object" &&231 ? authorizationServerMetadata.issuer
242 typeof (record.authorizationServerMetadata as Record<string, unknown>).issuer ===
243 "string"
244 ? ((record.authorizationServerMetadata as Record<string, unknown>)
245 .issuer as string)
246 : null,232 : null,
247 authorizationServerUrl:233 authorizationServerUrl:
248 typeof record.authorizationServerUrl === "string"234 typeof record.authorizationServerUrl === "string" ? record.authorizationServerUrl : null,
249 ? record.authorizationServerUrl
250 : null,
251 authorizationServerMetadataUrl:235 authorizationServerMetadataUrl:
252 typeof record.authorizationServerMetadataUrl === "string"236 typeof record.authorizationServerMetadataUrl === "string"
253 ? record.authorizationServerMetadataUrl237 ? record.authorizationServerMetadataUrl
254 : null,238 : null,
255 clientId:239 clientId: typeof clientInformation?.client_id === "string" ? clientInformation.client_id : "",
256 typeof clientInformation?.client_id === "string"
257 ? clientInformation.client_id
258 : "",
259 clientSecretSecretId: null,240 clientSecretSecretId: null,
260 clientAuth: "body",241 clientAuth: "body",
261 scope: null,242 scope: null,
@@ -288,10 +269,7 @@ export interface OAuthServiceDeps { · 26 unmodified lines
288 * `complete` (and from `start` for `client-credentials`). */269 * `complete` (and from `start` for `client-credentials`). */
289 readonly connectionsCreate: (270 readonly connectionsCreate: (
290 input: CreateConnectionInput,271 input: CreateConnectionInput,
291 ) => Effect.Effect<272 ) => Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure>;
292 ConnectionRef,
293 ConnectionProviderNotRegisteredError | StorageFailure
294 >;
295 /** Random session id generator. Tests override to make outputs273 /** Random session id generator. Tests override to make outputs
296 * deterministic. */274 * deterministic. */
297 readonly newSessionId?: () => string;275 readonly newSessionId?: () => string;
@@ -304,9 +282,9 @@ const defaultSessionId = (): string => { · 6 unmodified lines
304 if (crypto?.randomUUID) return `oauth2_session_${crypto.randomUUID()}`;282 if (crypto?.randomUUID) return `oauth2_session_${crypto.randomUUID()}`;
305 const bytes = new Uint8Array(16);283 const bytes = new Uint8Array(16);
306 crypto.getRandomValues(bytes);284 crypto.getRandomValues(bytes);
307 return `oauth2_session_${Array.from(bytes, (byte) =>285 return `oauth2_session_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
308 byte.toString(16).padStart(2, "0"),286 "",
309 ).join("")}`;287 )}`;
310};288};
311 289
312const secretIdPart = (value: string): string =>290const secretIdPart = (value: string): string =>
@@ -328,11 +306,7 @@ const oauthSecretId = ( · 15 unmodified lines
328const scopedSessionId = (scopeId: string, sessionId: string): string =>306const scopedSessionId = (scopeId: string, sessionId: string): string =>
329 `${sessionId}_${secretIdPart(scopeId).slice(0, 24)}`;307 `${sessionId}_${secretIdPart(scopeId).slice(0, 24)}`;
330 308
331const terminalRefreshErrors = new Set([309const terminalRefreshErrors = new Set(["invalid_grant", "invalid_client", "unauthorized_client"]);
332 "invalid_grant",
333 "invalid_client",
334 "unauthorized_client",
335]);
336 310
337// ---------------------------------------------------------------------------311// ---------------------------------------------------------------------------
338// Service factory312// Service factory
@@ -347,19 +321,16 @@ export const makeOAuth2Service = ( · 8 unmodified lines
347 // -------------------------------------------------------------------321 // -------------------------------------------------------------------
348 // probe322 // probe
349 // -------------------------------------------------------------------323 // -------------------------------------------------------------------
350 const probe = (324 const probe = (input: OAuthProbeInput): Effect.Effect<OAuthProbeResult, OAuthProbeError> =>
351 input: OAuthProbeInput,
352 ): Effect.Effect<OAuthProbeResult, OAuthProbeError> =>
353 Effect.gen(function* () {325 Effect.gen(function* () {
354 const resource = yield* discoverProtectedResourceMetadata(326 const resource = yield* discoverProtectedResourceMetadata(input.endpoint, {
355 input.endpoint,327 resourceHeaders: input.headers,
356 { resourceHeaders: input.headers, resourceQueryParams: input.queryParams },328 resourceQueryParams: input.queryParams,
357 ).pipe(329 }).pipe(
358 Effect.catchTag("OAuthDiscoveryError", (err) =>330 Effect.catchTag("OAuthDiscoveryError", () =>
359 Effect.fail(331 Effect.fail(
360 new OAuthProbeError({332 new OAuthProbeError({
361 message: `Protected resource metadata probe failed: ${err.message}`,333 message: "Protected resource metadata probe failed",
362
363 }),334 }),
364 ),335 ),
365 ),336 ),
@@ -368,29 +339,19 @@ export const makeOAuth2Service = ( · 2 unmodified lines
368 const authorizationServerUrl = (() => {339 const authorizationServerUrl = (() => {
369 const fromResource = resource?.metadata.authorization_servers?.[0];340 const fromResource = resource?.metadata.authorization_servers?.[0];
370 if (fromResource) return fromResource;341 if (fromResource) return fromResource;
371 try {342 const u = parseUrlOption(input.endpoint);
372 const u = new URL(input.endpoint);343 return u ? `${u.protocol}//${u.host}` : null;
373 return `${u.protocol}//${u.host}`;
374 } catch {
375 return null;
376 }
377 })();344 })();
378 345
379 const authServer = authorizationServerUrl346 const authServer = authorizationServerUrl
380 ? yield* discoverAuthorizationServerMetadata(347 ? yield* discoverAuthorizationServerMetadata(authorizationServerUrl).pipe(
381 authorizationServerUrl,348 Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)),
382 ).pipe(
383 Effect.catchTag("OAuthDiscoveryError", () =>
384 Effect.succeed(null),
385 ),
386 )349 )
387 : null;350 : null;
388 351
389 const supportsDynamicRegistration = !!(352 const supportsDynamicRegistration = !!(
390 authServer?.metadata.registration_endpoint &&353 authServer?.metadata.registration_endpoint &&
391 (authServer.metadata.token_endpoint_auth_methods_supported ?? []).includes(354 (authServer.metadata.token_endpoint_auth_methods_supported ?? []).includes("none")
392 "none",
393 )
394 );355 );
395 356
396 // Bearer challenge probe — POST the endpoint unauth, look for357 // Bearer challenge probe — POST the endpoint unauth, look for
@@ -400,47 +361,40 @@ export const makeOAuth2Service = ( · 3 unmodified lines
400 // challenge").361 // challenge").
401 const isBearerChallengeEndpoint = yield* Effect.tryPromise({362 const isBearerChallengeEndpoint = yield* Effect.tryPromise({
402 try: async (): Promise<boolean> => {363 try: async (): Promise<boolean> => {
403 const controller = new AbortController();364 const probeUrl = parseUrlOption(input.endpoint);
404 const timer = setTimeout(() => controller.abort(), 6_000);365 if (!probeUrl) return false;
405 try {366 for (const [key, value] of Object.entries(input.queryParams ?? {})) {
406 const probeUrl = new URL(input.endpoint);367 probeUrl.searchParams.set(key, value);
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);
436 }368 }
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);
437 },392 },
438 catch: () => null,393 catch: () => null,
439 }).pipe(Effect.catch(() => Effect.succeed(false)));394 }).pipe(Effect.catch(() => Effect.succeed(false)));
440 395
441 return {396 return {
442 resourceMetadata:397 resourceMetadata: (resource?.metadata as Record<string, unknown> | undefined) ?? null,
443 (resource?.metadata as Record<string, unknown> | undefined) ?? null,
444 resourceMetadataUrl: resource?.metadataUrl ?? null,398 resourceMetadataUrl: resource?.metadataUrl ?? null,
445 authorizationServerMetadata:399 authorizationServerMetadata:
446 (authServer?.metadata as Record<string, unknown> | undefined) ?? null,400 (authServer?.metadata as Record<string, unknown> | undefined) ?? null,
@@ -459,20 +413,22 @@ export const makeOAuth2Service = ( · 12 unmodified lines
459 strategy: OAuthDynamicDcrStrategy,413 strategy: OAuthDynamicDcrStrategy,
460 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>414 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
461 Effect.gen(function* () {415 Effect.gen(function* () {
462 const started = yield* beginDynamicAuthorization({416 const started = yield* beginDynamicAuthorization(
463 endpoint: input.endpoint,417 {
464 redirectUrl: input.redirectUrl,418 endpoint: input.endpoint,
465 state: "",419 redirectUrl: input.redirectUrl,
466 scopes: strategy.scopes,420 state: "",
467 }, {421 scopes: strategy.scopes,
468 resourceHeaders: input.headers,422 },
469 resourceQueryParams: input.queryParams,423 {
470 }).pipe(424 resourceHeaders: input.headers,
471 Effect.catchTag("OAuthDiscoveryError", (err) =>425 resourceQueryParams: input.queryParams,
426 },
427 ).pipe(
428 Effect.catchTag("OAuthDiscoveryError", () =>
472 Effect.fail(429 Effect.fail(
473 new OAuthStartError({430 new OAuthStartError({
474 message: `Dynamic authorization setup failed: ${err.message}`,431 message: "Dynamic authorization setup failed",
475
476 }),432 }),
477 ),433 ),
478 ),434 ),
@@ -492,10 +448,7 @@ export const makeOAuth2Service = ( · 13 unmodified lines
492 authorizationUrl: started.state.authorizationServerMetadata.authorization_endpoint,448 authorizationUrl: started.state.authorizationServerMetadata.authorization_endpoint,
493 clientId: started.state.clientInformation.client_id,449 clientId: started.state.clientInformation.client_id,
494 redirectUrl: input.redirectUrl,450 redirectUrl: input.redirectUrl,
495 scopes:451 scopes: strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? [],
496 strategy.scopes ??
497 started.state.authorizationServerMetadata.scopes_supported ??
498 [],
499 state: sessionId,452 state: sessionId,
500 codeChallenge,453 codeChallenge,
501 });454 });
@@ -505,22 +458,20 @@ export const makeOAuth2Service = ( · 3 unmodified lines
505 identityLabel: input.identityLabel ?? null,458 identityLabel: input.identityLabel ?? null,
506 codeVerifier: started.codeVerifier,459 codeVerifier: started.codeVerifier,
507 authorizationServerUrl: started.state.authorizationServerUrl,460 authorizationServerUrl: started.state.authorizationServerUrl,
508 authorizationServerMetadataUrl:461 authorizationServerMetadataUrl: started.state.authorizationServerMetadataUrl,
509 started.state.authorizationServerMetadataUrl,462 authorizationServerMetadata: started.state.authorizationServerMetadata as Record<
510 authorizationServerMetadata:463 string,
511 started.state.authorizationServerMetadata as Record<string, unknown>,464 unknown
465 >,
512 clientInformation: (() => {466 clientInformation: (() => {
513 const value: unknown = started.state.clientInformation;467 const value: unknown = started.state.clientInformation;
514 return value as Record<string, unknown>;468 return value as Record<string, unknown>;
515 })(),469 })(),
516 resourceMetadataUrl: started.state.resourceMetadataUrl,470 resourceMetadataUrl: started.state.resourceMetadataUrl,
517 resourceMetadata:471 resourceMetadata:
518 (started.state.resourceMetadata as Record<string, unknown> | null) ??472 (started.state.resourceMetadata as Record<string, unknown> | null) ?? null,
519 null,
520 scopes: [473 scopes: [
521 ...(strategy.scopes ??474 ...(strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? []),
522 started.state.authorizationServerMetadata.scopes_supported ??
523 []),
524 ],475 ],
525 };476 };
526 477
@@ -544,25 +495,22 @@ export const makeOAuth2Service = ( · 17 unmodified lines
544 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>495 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
545 Effect.gen(function* () {496 Effect.gen(function* () {
546 const clientId = yield* deps.secretsGet(strategy.clientIdSecretId).pipe(497 const clientId = yield* deps.secretsGet(strategy.clientIdSecretId).pipe(
547 Effect.mapError((err) =>498 Effect.mapError(
548 // Storage failure propagates; null returns aren't errors — the499 (err) =>
549 // branch below handles them.500 // Storage failure propagates; null returns aren't errors — the
550 err,501 // branch below handles them.
502 err,
551 ),503 ),
552 );504 );
553 if (clientId === null) {505 if (clientId === null) {
554 return yield* Effect.fail(506 return yield* new OAuthStartError({
555 new OAuthStartError({507 message: `client_id secret "${strategy.clientIdSecretId}" not found`,
556 message: `client_id secret "${strategy.clientIdSecretId}" not found`,508 });
557 }),
558 );
559 }509 }
560 510
561 const sessionId = scopedSessionId(input.tokenScope, newSessionId());511 const sessionId = scopedSessionId(input.tokenScope, newSessionId());
562 const codeVerifier = createPkceCodeVerifier();512 const codeVerifier = createPkceCodeVerifier();
563 const codeChallenge = yield* Effect.promise(() =>513 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
564 createPkceCodeChallenge(codeVerifier),
565 );
566 514
567 const authorizationUrl = buildAuthorizationUrl({515 const authorizationUrl = buildAuthorizationUrl({
568 authorizationUrl: strategy.authorizationEndpoint,516 authorizationUrl: strategy.authorizationEndpoint,
@@ -611,11 +559,9 @@ export const makeOAuth2Service = ( · 42 unmodified lines
611 const clientId = yield* deps.secretsGet(strategy.clientIdSecretId);559 const clientId = yield* deps.secretsGet(strategy.clientIdSecretId);
612 const clientSecret = yield* deps.secretsGet(strategy.clientSecretSecretId);560 const clientSecret = yield* deps.secretsGet(strategy.clientSecretSecretId);
613 if (clientId === null || clientSecret === null) {561 if (clientId === null || clientSecret === null) {
614 return yield* Effect.fail(562 return yield* new OAuthStartError({
615 new OAuthStartError({563 message: "client_id / client_secret secret not found",
616 message: "client_id / client_secret secret not found",564 });
617 }),
618 );
619 }565 }
620 566
621 const tokens = yield* exchangeClientCredentials({567 const tokens = yield* exchangeClientCredentials({
@@ -627,18 +573,15 @@ export const makeOAuth2Service = ( · 5 unmodified lines
627 clientAuth: strategy.clientAuth ?? "body",573 clientAuth: strategy.clientAuth ?? "body",
628 }).pipe(574 }).pipe(
629 Effect.mapError(575 Effect.mapError(
630 (err) =>576 () =>
631 new OAuthStartError({577 new OAuthStartError({
632 message: `Client credentials exchange failed: ${err.message}`,578 message: "Client credentials exchange failed",
633
634 }),579 }),
635 ),580 ),
636 );581 );
637 582
638 const expiresAt =583 const expiresAt =
639 typeof tokens.expires_in === "number"584 typeof tokens.expires_in === "number" ? now() + tokens.expires_in * 1000 : null;
640 ? now() + tokens.expires_in * 1000
641 : null;
642 585
643 const providerState: OAuthProviderState = {586 const providerState: OAuthProviderState = {
644 kind: "client-credentials",587 kind: "client-credentials",
@@ -666,19 +609,17 @@ export const makeOAuth2Service = ( · 21 unmodified lines
666 refreshToken: null,609 refreshToken: null,
667 expiresAt,610 expiresAt,
668 oauthScope: tokens.scope ?? null,611 oauthScope: tokens.scope ?? null,
669 providerState: Schema.encodeSync(OAuthProviderStateSchema)(612 providerState: Schema.encodeSync(OAuthProviderStateSchema)(providerState) as Record<
670 providerState,613 string,
671 ) as Record<string, unknown>,614 unknown
615 >,
672 }),616 }),
673 )617 )
674 .pipe(618 .pipe(
675 Effect.mapError(619 Effect.mapError(
676 (err) =>620 () =>
677 new OAuthStartError({621 new OAuthStartError({
678 message: `Failed to mint connection: ${622 message: "Failed to mint connection",
679 err instanceof Error ? err.message : String(err)
680 }`,
681
682 }),623 }),
683 ),624 ),
684 );625 );
@@ -709,22 +650,24 @@ export const makeOAuth2Service = ( · 24 unmodified lines
709 payload: OAuthSessionPayload;650 payload: OAuthSessionPayload;
710 strategyKind: string;651 strategyKind: string;
711 }): Effect.Effect<void, StorageFailure> =>652 }): Effect.Effect<void, StorageFailure> =>
712 deps.adapter.create({653 deps.adapter
713 model: "oauth2_session",654 .create({
714 data: {655 model: "oauth2_session",
715 id: args.sessionId,656 data: {
716 scope_id: args.input.tokenScope,657 id: args.sessionId,
717 plugin_id: args.input.pluginId,658 scope_id: args.input.tokenScope,
718 strategy: args.strategyKind,659 plugin_id: args.input.pluginId,
719 connection_id: args.input.connectionId,660 strategy: args.strategyKind,
720 token_scope: args.input.tokenScope,661 connection_id: args.input.connectionId,
721 redirect_url: args.input.redirectUrl,662 token_scope: args.input.tokenScope,
722 payload: encodeSessionPayload(args.payload) as Record<string, unknown>,663 redirect_url: args.input.redirectUrl,
723 expires_at: now() + OAUTH2_SESSION_TTL_MS,664 payload: encodeSessionPayload(args.payload) as Record<string, unknown>,
724 created_at: new Date(),665 expires_at: now() + OAUTH2_SESSION_TTL_MS,
725 },666 created_at: new Date(),
726 forceAllowId: true,667 },
727 }).pipe(Effect.asVoid);668 forceAllowId: true,
669 })
670 .pipe(Effect.asVoid);
728 671
729 // -------------------------------------------------------------------672 // -------------------------------------------------------------------
730 // complete — exchange the code, mint the Connection, delete the session673 // complete — exchange the code, mint the Connection, delete the session
@@ -741,53 +684,45 @@ export const makeOAuth2Service = ( · 10 unmodified lines
741 where: [{ field: "id", value: input.state }],684 where: [{ field: "id", value: input.state }],
742 });685 });
743 if (!row) {686 if (!row) {
744 return yield* Effect.fail(687 return yield* new OAuthSessionNotFoundError({ sessionId: input.state });
745 new OAuthSessionNotFoundError({ sessionId: input.state }),
746 );
747 }688 }
748 689
749 const deleteSession = deps.adapter.delete({690 const deleteSession = deps.adapter.delete({
750 model: "oauth2_session",691 model: "oauth2_session",
751 where: [692 where: [
752 { field: "id", value: input.state },693 { field: "id", value: input.state },
753 { field: "scope_id", value: row.scope_id as string },694 { field: "scope_id", value: row.scope_id },
754 ],695 ],
755 });696 });
756 697
757 if (input.error) {698 if (input.error) {
758 yield* deleteSession;699 yield* deleteSession;
759 return yield* Effect.fail(700 return yield* new OAuthCompleteError({
760 new OAuthCompleteError({701 message: `Authorization server returned error: ${input.error}`,
761 message: `Authorization server returned error: ${input.error}`,702 code: input.error,
762 code: input.error,703 });
763 }),
764 );
765 }704 }
766 if (!input.code) {705 if (!input.code) {
767 yield* deleteSession;706 yield* deleteSession;
768 return yield* Effect.fail(707 return yield* new OAuthCompleteError({
769 new OAuthCompleteError({708 message: "Missing authorization code",
770 message: "Missing authorization code",709 });
771 }),
772 );
773 }710 }
774 const expiresAt = Number(row.expires_at as number | bigint);711 const expiresAt = Number(row.expires_at as number | bigint);
775 if (expiresAt <= now()) {712 if (expiresAt <= now()) {
776 yield* deleteSession;713 yield* deleteSession;
777 return yield* Effect.fail(714 return yield* new OAuthCompleteError({
778 new OAuthCompleteError({715 message: "OAuth session expired",
779 message: "OAuth session expired",716 });
780 }),
781 );
782 }717 }
783 718
784 const payload = decodeSessionPayload(coerceJson(row.payload));719 const payload = decodeSessionPayload(coerceJson(row.payload));
785 const endpoint = ""; // not stored on the row — the payload's own720 const endpoint = ""; // not stored on the row — the payload's own
786 // endpoint fields drive exchange; we just need721 // endpoint fields drive exchange; we just need
787 // a display string for the identity label.722 // a display string for the identity label.
788 const connectionId = row.connection_id as string;723 const connectionId = row.connection_id;
789 const tokenScope = row.token_scope as string;724 const tokenScope = row.token_scope;
790 const redirectUrl = row.redirect_url as string;725 const redirectUrl = row.redirect_url;
791 726
792 // Dispatch to the strategy-specific exchange.727 // Dispatch to the strategy-specific exchange.
793 const exchangeResult = yield* (() => {728 const exchangeResult = yield* (() => {
@@ -795,11 +730,7 @@ export const makeOAuth2Service = ( · 1 unmodified line
795 case "dynamic-dcr":730 case "dynamic-dcr":
796 return exchangeDynamicDcr(payload, input.code, redirectUrl);731 return exchangeDynamicDcr(payload, input.code, redirectUrl);
797 case "authorization-code":732 case "authorization-code":
798 return exchangeAuthorizationCodeStrategy(733 return exchangeAuthorizationCodeStrategy(payload, input.code, redirectUrl);
799 payload,
800 input.code,
801 redirectUrl,
802 );
803 }734 }
804 })().pipe(Effect.tapError(() => deleteSession));735 })().pipe(Effect.tapError(() => deleteSession));
805 736
@@ -828,11 +759,9 @@ export const makeOAuth2Service = ( · 22 unmodified lines
828 .pipe(759 .pipe(
829 Effect.as(secretId),760 Effect.as(secretId),
830 Effect.mapError(761 Effect.mapError(
831 (err) =>762 () =>
832 new OAuthCompleteError({763 new OAuthCompleteError({
833 message: `Failed to persist DCR client_secret: ${764 message: "Failed to persist DCR client_secret",
834 err instanceof Error ? err.message : String(err)
835 }`,
836 }),765 }),
837 ),766 ),
838 );767 );
@@ -842,21 +771,21 @@ export const makeOAuth2Service = ( · 3 unmodified lines
842 payload.kind === "dynamic-dcr"771 payload.kind === "dynamic-dcr"
843 ? {772 ? {
844 kind: "dynamic-dcr",773 kind: "dynamic-dcr",
845 tokenEndpoint: (payload.authorizationServerMetadata as {774 tokenEndpoint: (
846 token_endpoint: string;775 payload.authorizationServerMetadata as {
847 }).token_endpoint,776 token_endpoint: string;
777 }
778 ).token_endpoint,
848 issuerUrl:779 issuerUrl:
849 (payload.authorizationServerMetadata as { issuer?: string }).issuer ??780 (payload.authorizationServerMetadata as { issuer?: string }).issuer ?? null,
850 null,
851 authorizationServerUrl: payload.authorizationServerUrl,781 authorizationServerUrl: payload.authorizationServerUrl,
852 authorizationServerMetadataUrl:782 authorizationServerMetadataUrl: payload.authorizationServerMetadataUrl,
853 payload.authorizationServerMetadataUrl,783 idTokenSigningAlgValuesSupported: (
854 idTokenSigningAlgValuesSupported:784 payload.authorizationServerMetadata as {
855 (payload.authorizationServerMetadata as {
856 id_token_signing_alg_values_supported?: string[];785 id_token_signing_alg_values_supported?: string[];
857 }).id_token_signing_alg_values_supported,786 }
858 clientId: (payload.clientInformation as { client_id: string })787 ).id_token_signing_alg_values_supported,
859 .client_id,788 clientId: (payload.clientInformation as { client_id: string }).client_id,
860 clientSecretSecretId: dynamicClientSecretSecretId,789 clientSecretSecretId: dynamicClientSecretSecretId,
861 clientAuth:790 clientAuth:
862 (payload.clientInformation as { token_endpoint_auth_method?: string })791 (payload.clientInformation as { token_endpoint_auth_method?: string })
@@ -901,19 +830,17 @@ export const makeOAuth2Service = ( · 38 unmodified lines
901 : null,830 : null,
902 expiresAt: connectionExpiresAt,831 expiresAt: connectionExpiresAt,
903 oauthScope: exchangeResult.tokens.scope ?? null,832 oauthScope: exchangeResult.tokens.scope ?? null,
904 providerState: Schema.encodeSync(OAuthProviderStateSchema)(833 providerState: Schema.encodeSync(OAuthProviderStateSchema)(providerState) as Record<
905 providerState,834 string,
906 ) as Record<string, unknown>,835 unknown
836 >,
907 }),837 }),
908 )838 )
909 .pipe(839 .pipe(
910 Effect.mapError(840 Effect.mapError(
911 (err) =>841 () =>
912 new OAuthCompleteError({842 new OAuthCompleteError({
913 message: `Failed to mint connection: ${843 message: "Failed to mint connection",
914 err instanceof Error ? err.message : String(err)
915 }`,
916
917 }),844 }),
918 ),845 ),
919 );846 );
@@ -962,19 +889,14 @@ export const makeOAuth2Service = ( · 42 unmodified lines
962 redirectUrl,889 redirectUrl,
963 codeVerifier: payload.codeVerifier,890 codeVerifier: payload.codeVerifier,
964 code,891 code,
965 idTokenSigningAlgValuesSupported:892 idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported,
966 md.id_token_signing_alg_values_supported,893 clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body",
967 clientAuth:
968 ci.token_endpoint_auth_method === "client_secret_basic"
969 ? "basic"
970 : "body",
971 }).pipe(894 }).pipe(
972 Effect.mapError(895 Effect.mapError(
973 (err) =>896 (err) =>
974 new OAuthCompleteError({897 new OAuthCompleteError({
975 message: `Token exchange failed: ${err.message}`,898 message: "Token exchange failed",
976 code: err.error,899 code: err.error,
977
978 }),900 }),
979 ),901 ),
980 );902 );
@@ -988,28 +910,21 @@ export const makeOAuth2Service = ( · 7 unmodified lines
988 payload: Extract<OAuthSessionPayload, { kind: "authorization-code" }>,910 payload: Extract<OAuthSessionPayload, { kind: "authorization-code" }>,
989 code: string,911 code: string,
990 redirectUrl: string,912 redirectUrl: string,
991 ): Effect.Effect<913 ): Effect.Effect<ExchangeResult, OAuthCompleteError | StorageFailure> =>
992 ExchangeResult,
993 OAuthCompleteError | StorageFailure
994 > =>
995 Effect.gen(function* () {914 Effect.gen(function* () {
996 const clientId = yield* deps.secretsGet(payload.clientIdSecretId);915 const clientId = yield* deps.secretsGet(payload.clientIdSecretId);
997 if (clientId === null) {916 if (clientId === null) {
998 return yield* Effect.fail(917 return yield* new OAuthCompleteError({
999 new OAuthCompleteError({918 message: `client_id secret "${payload.clientIdSecretId}" not found`,
1000 message: `client_id secret "${payload.clientIdSecretId}" not found`,919 });
1001 }),
1002 );
1003 }920 }
1004 const clientSecret = payload.clientSecretSecretId921 const clientSecret = payload.clientSecretSecretId
1005 ? yield* deps.secretsGet(payload.clientSecretSecretId)922 ? yield* deps.secretsGet(payload.clientSecretSecretId)
1006 : null;923 : null;
1007 if (payload.clientSecretSecretId && clientSecret === null) {924 if (payload.clientSecretSecretId && clientSecret === null) {
1008 return yield* Effect.fail(925 return yield* new OAuthCompleteError({
1009 new OAuthCompleteError({926 message: `client_secret secret "${payload.clientSecretSecretId}" not found`,
1010 message: `client_secret secret "${payload.clientSecretSecretId}" not found`,927 });
1011 }),
1012 );
1013 }928 }
1014 929
1015 const tokens = yield* exchangeAuthorizationCode({930 const tokens = yield* exchangeAuthorizationCode({
@@ -1025,9 +940,8 @@ export const makeOAuth2Service = ( · 9 unmodified lines
1025 Effect.mapError(940 Effect.mapError(
1026 (err) =>941 (err) =>
1027 new OAuthCompleteError({942 new OAuthCompleteError({
1028 message: `Token exchange failed: ${err.message}`,943 message: "Token exchange failed",
1029 code: err.error,944 code: err.error,
1030
1031 }),945 }),
1032 ),946 ),
1033 );947 );
@@ -1048,7 +962,7 @@ export const makeOAuth2Service = ( · 14 unmodified lines
1048 model: "oauth2_session",962 model: "oauth2_session",
1049 where: [963 where: [
1050 { field: "id", value: sessionId },964 { field: "id", value: sessionId },
1051 { field: "scope_id", value: row.scope_id as string },965 { field: "scope_id", value: row.scope_id },
1052 ],966 ],
1053 });967 });
1054 });968 });
@@ -1071,9 +985,7 @@ export const makeOAuth2Service = ( · 16 unmodified lines
1071 catch: (cause) =>985 catch: (cause) =>
1072 new ConnectionRefreshError({986 new ConnectionRefreshError({
1073 connectionId: input.connectionId,987 connectionId: input.connectionId,
1074 message: `oauth2 providerState is malformed: ${988 message: "oauth2 providerState is malformed",
1075 cause instanceof Error ? cause.message : String(cause)
1076 }`,
1077 cause,989 cause,
1078 }),990 }),
1079 });991 });
@@ -1095,22 +1007,16 @@ export const makeOAuth2Service = ( · 15 unmodified lines
1095 case "dynamic-dcr":1007 case "dynamic-dcr":
1096 return Effect.gen(function* () {1008 return Effect.gen(function* () {
1097 const csec = state.clientSecretSecretId1009 const csec = state.clientSecretSecretId
1098 ? yield* deps1010 ? yield* deps.secretsGet(state.clientSecretSecretId).pipe(
1099 .secretsGet(state.clientSecretSecretId)1011 Effect.mapError(
1100 .pipe(1012 (cause) =>
1101 Effect.mapError(1013 new ConnectionRefreshError({
1102 (cause) =>1014 connectionId: input.connectionId,
1103 new ConnectionRefreshError({1015 message: "Failed to resolve DCR client_secret",
1104 connectionId: input.connectionId,1016 cause,
1105 message: `Failed to resolve DCR client_secret: ${1017 }),
1106 cause instanceof Error1018 ),
1107 ? cause.message1019 )
1108 : String(cause)
1109 }`,
1110 cause,
1111 }),
1112 ),
1113 )
1114 : null;1020 : null;
1115 if (state.clientSecretSecretId && csec === null) {1021 if (state.clientSecretSecretId && csec === null) {
1116 return yield* new ConnectionRefreshError({1022 return yield* new ConnectionRefreshError({
@@ -1124,22 +1030,16 @@ export const makeOAuth2Service = ( · 7 unmodified lines
1124 case "authorization-code":1030 case "authorization-code":
1125 case "client-credentials":1031 case "client-credentials":
1126 return Effect.gen(function* () {1032 return Effect.gen(function* () {
1127 const cid = yield* deps1033 const cid = yield* deps.secretsGet(state.clientIdSecretId).pipe(
1128 .secretsGet(state.clientIdSecretId)1034 Effect.mapError(
1129 .pipe(1035 (cause) =>
1130 Effect.mapError(1036 new ConnectionRefreshError({
1131 (cause) =>1037 connectionId: input.connectionId,
1132 new ConnectionRefreshError({1038 message: "Failed to resolve client_id secret",
1133 connectionId: input.connectionId,1039 cause,
1134 message: `Failed to resolve client_id secret: ${1040 }),
1135 cause instanceof Error1041 ),
1136 ? cause.message1042 );
1137 : String(cause)
1138 }`,
1139 cause,
1140 }),
1141 ),
1142 );
1143 if (cid === null) {1043 if (cid === null) {
1144 return yield* new ConnectionRefreshError({1044 return yield* new ConnectionRefreshError({
1145 connectionId: input.connectionId,1045 connectionId: input.connectionId,
@@ -1148,22 +1048,16 @@ export const makeOAuth2Service = ( · 2 unmodified lines
1148 });1048 });
1149 }1049 }
1150 const csec = state.clientSecretSecretId1050 const csec = state.clientSecretSecretId
1151 ? yield* deps1051 ? yield* deps.secretsGet(state.clientSecretSecretId).pipe(
1152 .secretsGet(state.clientSecretSecretId)1052 Effect.mapError(
1153 .pipe(1053 (cause) =>
1154 Effect.mapError(1054 new ConnectionRefreshError({
1155 (cause) =>1055 connectionId: input.connectionId,
1156 new ConnectionRefreshError({1056 message: "Failed to resolve client_secret",
1157 connectionId: input.connectionId,1057 cause,
1158 message: `Failed to resolve client_secret: ${1058 }),
1159 cause instanceof Error1059 ),
1160 ? cause.message1060 )
1161 : String(cause)
1162 }`,
1163 cause,
1164 }),
1165 ),
1166 )
1167 : null;1061 : null;
1168 if (state.clientSecretSecretId && csec === null) {1062 if (state.clientSecretSecretId && csec === null) {
1169 return yield* new ConnectionRefreshError({1063 return yield* new ConnectionRefreshError({
@@ -1179,32 +1073,25 @@ export const makeOAuth2Service = ( · 9 unmodified lines
1179 1073
1180 const tokenEndpoint = yield* (() => {1074 const tokenEndpoint = yield* (() => {
1181 if (state.tokenEndpoint) return Effect.succeed(state.tokenEndpoint);1075 if (state.tokenEndpoint) return Effect.succeed(state.tokenEndpoint);
1182 if (1076 if (state.kind === "dynamic-dcr" && state.authorizationServerUrl) {
1183 state.kind === "dynamic-dcr" &&1077 return discoverAuthorizationServerMetadata(state.authorizationServerUrl).pipe(
1184 state.authorizationServerUrl
1185 ) {
1186 return discoverAuthorizationServerMetadata(
1187 state.authorizationServerUrl,
1188 ).pipe(
1189 Effect.flatMap((metadata) =>1078 Effect.flatMap((metadata) =>
1190 metadata?.metadata.token_endpoint1079 metadata?.metadata.token_endpoint
1191 ? Effect.succeed(metadata.metadata.token_endpoint)1080 ? Effect.succeed(metadata.metadata.token_endpoint)
1192 : Effect.fail(1081 : Effect.fail(
1193 new ConnectionRefreshError({1082 new ConnectionRefreshError({
1194 connectionId: input.connectionId,1083 connectionId: input.connectionId,
1195 message:1084 message: "oauth2 legacy MCP providerState is missing token endpoint",
1196 "oauth2 legacy MCP providerState is missing token endpoint",
1197 reauthRequired: true,1085 reauthRequired: true,
1198 }),1086 }),
1199 ),1087 ),
1200 ),1088 ),
1201 Effect.mapError((cause) =>1089 Effect.mapError((cause) =>
1202 cause instanceof ConnectionRefreshError1090 Predicate.isTagged("ConnectionRefreshError")(cause)
1203 ? cause1091 ? cause
1204 : new ConnectionRefreshError({1092 : new ConnectionRefreshError({
1205 connectionId: input.connectionId,1093 connectionId: input.connectionId,
1206 message:1094 message: "Failed to discover token endpoint for legacy MCP OAuth connection",
1207 "Failed to discover token endpoint for legacy MCP OAuth connection",
1208 reauthRequired: true,1095 reauthRequired: true,
1209 cause,1096 cause,
1210 }),1097 }),
@@ -1220,56 +1107,51 @@ export const makeOAuth2Service = ( · 9 unmodified lines
1220 );1107 );
1221 })();1108 })();
1222 1109
1223 const tokens = yield* (state.kind === "client-credentials"1110 const tokens = yield* (
1224 ? exchangeClientCredentials({1111 state.kind === "client-credentials"
1225 tokenUrl: tokenEndpoint,1112 ? exchangeClientCredentials({
1226 clientId,1113 tokenUrl: tokenEndpoint,
1227 clientSecret: clientSecret ?? "",1114 clientId,
1228 scopes: state.scopes,1115 clientSecret: clientSecret ?? "",
1229 scopeSeparator: state.scopeSeparator,1116 scopes: state.scopes,
1230 clientAuth: state.clientAuth,1117 scopeSeparator: state.scopeSeparator,
1231 })1118 clientAuth: state.clientAuth,
1232 : refreshAccessToken({1119 })
1233 tokenUrl: tokenEndpoint,1120 : refreshAccessToken({
1234 issuerUrl:1121 tokenUrl: tokenEndpoint,
1235 state.kind === "dynamic-dcr" || state.kind === "authorization-code"1122 issuerUrl:
1236 ? (state.issuerUrl ?? undefined)1123 state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1237 : undefined,1124 ? (state.issuerUrl ?? undefined)
1238 clientId,1125 : undefined,
1239 clientSecret: clientSecret ?? undefined,1126 clientId,
1240 refreshToken: input.refreshToken!,1127 clientSecret: clientSecret ?? undefined,
1241 scopes:1128 refreshToken: input.refreshToken!,
1242 state.kind === "dynamic-dcr" || state.kind === "authorization-code"1129 scopes:
1243 ? state.scopes1130 state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1244 : undefined,1131 ? state.scopes
1245 scopeSeparator:1132 : undefined,
1246 state.kind === "dynamic-dcr" || state.kind === "authorization-code"1133 scopeSeparator:
1247 ? state.scopeSeparator1134 state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1248 : undefined,1135 ? state.scopeSeparator
1249 clientAuth: state.clientAuth,1136 : undefined,
1250 idTokenSigningAlgValuesSupported:1137 clientAuth: state.clientAuth,
1251 state.kind === "dynamic-dcr"1138 idTokenSigningAlgValuesSupported:
1252 ? state.idTokenSigningAlgValuesSupported1139 state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined,
1253 : undefined,1140 })
1254 })).pipe(1141 ).pipe(
1255 Effect.mapError(1142 Effect.mapError(
1256 (err) =>1143 (err) =>
1257 new ConnectionRefreshError({1144 new ConnectionRefreshError({
1258 connectionId: input.connectionId,1145 connectionId: input.connectionId,
1259 message: `OAuth refresh failed: ${err.message}`,1146 message: "OAuth refresh failed",
1260 // Terminal RFC 6749 §5.2 errors mean retrying won't heal it.1147 // Terminal RFC 6749 §5.2 errors mean retrying won't heal it.
1261 reauthRequired: err.error1148 reauthRequired: err.error ? terminalRefreshErrors.has(err.error) : false,
1262 ? terminalRefreshErrors.has(err.error)
1263 : false,
1264
1265 }),1149 }),
1266 ),1150 ),
1267 );1151 );
1268 1152
1269 const expiresAt =1153 const expiresAt =
1270 typeof tokens.expires_in === "number"1154 typeof tokens.expires_in === "number" ? now() + tokens.expires_in * 1000 : null;
1271 ? now() + tokens.expires_in * 1000
1272 : null;
1273 1155
1274 const result: ConnectionRefreshResult = {1156 const result: ConnectionRefreshResult = {
1275 accessToken: tokens.access_token,1157 accessToken: tokens.access_token,
@@ -1293,9 +1175,5 @@ export const makeOAuth2Service = ( · 17 unmodified lines
1293 1175
1294const safeHostname = (value: string | null): string | null => {1176const safeHostname = (value: string | null): string | null => {
1295 if (!value) return null;1177 if (!value) return null;
1296 try {1178 return parseUrlOption(value)?.host ?? value;
1297 return new URL(value).host;
1298 } catch {
1299 return value;
1300 }
1301};1179};