History

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

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

Rhys Sullivan committed 6 months ago

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

Unified · Split

2 files
  1. packages/core/sdk/src/oauth-service.ts +268 −390
  2. packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +168 −206
packages/core/sdk/src/oauth-service.ts+268−390
@@ -35,13 +35,9 @@ · 34 unmodified lines
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};
packages/plugins/openapi/src/sdk/oauth-refresh.test.ts+168−206
@@ -13,8 +13,14 @@ · 12 unmodified lines
13// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
14 14
15import { afterEach, expect, layer } from "@effect/vitest";15import { afterEach, expect, layer } from "@effect/vitest";
16import { Effect, Layer, Schema } from "effect";16import { Effect, Layer, Predicate, Schema } from "effect";
17import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";17import {
18 HttpApi,
19 HttpApiBuilder,
20 HttpApiEndpoint,
21 HttpApiGroup,
22 OpenApi,
23} from "effect/unstable/httpapi";
18import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";24import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";
19import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";25import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
20 26
@@ -40,13 +46,6 @@ import { OAuth2Auth } from "./types"; · 19 unmodified lines
40 46
41const autoApprove: InvokeOptions = { onElicitation: "accept-all" };47const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
42 48
43class OpenApiOauthTestSetupError extends Schema.TaggedErrorClass<OpenApiOauthTestSetupError>()(
44 "OpenApiOauthTestSetupError",
45 {
46 message: Schema.String,
47 },
48) {}
49
50// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
51// Test API — one endpoint that echoes the Authorization header so we can50// Test API — one endpoint that echoes the Authorization header so we can
52// prove which access token was in flight at invoke time.51// prove which access token was in flight at invoke time.
@@ -96,23 +95,26 @@ const mockTokenFetch = ( · 43 unmodified lines
96 handler: (body: URLSearchParams) => Effect.Effect<Response, never, never> | Promise<Response>,95 handler: (body: URLSearchParams) => Effect.Effect<Response, never, never> | Promise<Response>,
97) => {96) => {
98 const calls: TokenCall[] = [];97 const calls: TokenCall[] = [];
99 globalThis.fetch = Object.assign(async (_input: RequestInfo | URL, init?: RequestInit) => {98 globalThis.fetch = Object.assign(
100 const url = typeof _input === "string" ? _input : _input.toString();99 async (_input: RequestInfo | URL, init?: RequestInit) => {
101 if (!url.includes("token.example.com")) {100 const url = typeof _input === "string" ? _input : _input.toString();
102 return originalFetch(_input, init);101 if (!url.includes("token.example.com")) {
103 }102 return originalFetch(_input, init);
104 const bodyText =103 }
105 init?.body instanceof URLSearchParams104 const bodyText =
106 ? init.body.toString()105 init?.body instanceof URLSearchParams
107 : typeof init?.body === "string"106 ? init.body.toString()
108 ? init.body107 : typeof init?.body === "string"
109 : "";108 ? init.body
110 const body = new URLSearchParams(bodyText);109 : "";
111 calls.push({ body });110 const body = new URLSearchParams(bodyText);
112 const out = handler(body);111 calls.push({ body });
113 if (Effect.isEffect(out)) return await Effect.runPromise(out);112 const out = handler(body);
114 return await out;113 if (Effect.isEffect(out)) return await Effect.runPromise(out);
115 }, { preconnect: originalFetch.preconnect });114 return await out;
115 },
116 { preconnect: originalFetch.preconnect },
117 );
116 return { calls };118 return { calls };
117};119};
118 120
@@ -133,14 +135,12 @@ const makeExecutor = () => · 14 unmodified lines
133 const memoryProvider: SecretProvider = {135 const memoryProvider: SecretProvider = {
134 key: "memory",136 key: "memory",
135 writable: true,137 writable: true,
136 get: (id, scope) =>138 get: (id, scope) => Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null),
137 Effect.sync(() => secretStore.get(keyOf(scope, id)) ?? null),
138 set: (id, value, scope) =>139 set: (id, value, scope) =>
139 Effect.sync(() => {140 Effect.sync(() => {
140 secretStore.set(keyOf(scope, id), value);141 secretStore.set(keyOf(scope, id), value);
141 }),142 }),
142 delete: (id, scope) =>143 delete: (id, scope) => Effect.sync(() => secretStore.delete(keyOf(scope, id))),
143 Effect.sync(() => secretStore.delete(keyOf(scope, id))),
144 };144 };
145 const memorySecretsPlugin = definePlugin(() => ({145 const memorySecretsPlugin = definePlugin(() => ({
146 id: "memory-secrets" as const,146 id: "memory-secrets" as const,
@@ -150,10 +150,9 @@ const makeExecutor = () => · 3 unmodified lines
150 const clientLayer = FetchHttpClient.layer;150 const clientLayer = FetchHttpClient.layer;
151 const server = yield* HttpServer.HttpServer;151 const server = yield* HttpServer.HttpServer;
152 const address = server.address;152 const address = server.address;
153 if (!("port" in address)) {153 if (!Predicate.isTagged("TcpAddress")(address)) {
154 return yield* new OpenApiOauthTestSetupError({154 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test harness cannot continue without a TCP test server address
155 message: "Test server must bind to TCP",155 return yield* Effect.die("test server must bind to TCP");
156 });
157 }156 }
158 const baseUrl = `http://127.0.0.1:${address.port}`;157 const baseUrl = `http://127.0.0.1:${address.port}`;
159 const plugins = [158 const plugins = [
@@ -208,11 +207,7 @@ type ExecutorValue = EffectSuccess<ReturnType<typeof makeExecutor>>["executor"]; · 48 unmodified lines
208// Seed an authorizationCode Connection with an already-expired access207// Seed an authorizationCode Connection with an already-expired access
209// token and a stored refresh token. The test's mock token endpoint208// token and a stored refresh token. The test's mock token endpoint
210// decides what comes back on `grant_type=refresh_token`.209// decides what comes back on `grant_type=refresh_token`.
211const seedExpiredConnection = (210const seedExpiredConnection = (executor: ExecutorValue, scopeId: ScopeId, connectionId: string) =>
212 executor: ExecutorValue,
213 scopeId: ScopeId,
214 connectionId: string,
215) =>
216 Effect.gen(function* () {211 Effect.gen(function* () {
217 yield* executor.connections.create(212 yield* executor.connections.create(
218 new CreateConnectionInput({213 new CreateConnectionInput({
@@ -259,177 +254,144 @@ const seedExpiredConnection = ( · 40 unmodified lines
259// ---------------------------------------------------------------------------254// ---------------------------------------------------------------------------
260 255
261layer(TestLayer)("OpenAPI oauth refresh", (it) => {256layer(TestLayer)("OpenAPI oauth refresh", (it) => {
262 it.effect(257 it.effect("expired access_token is refreshed via grant_type=refresh_token before invoke", () =>
263 "expired access_token is refreshed via grant_type=refresh_token before invoke",258 Effect.gen(function* () {
264 () =>259 const { executor, scopeId, baseUrl } = yield* makeExecutor();
265 Effect.gen(function* () {260 const { calls } = mockTokenFetch(() =>
266 const { executor, scopeId, baseUrl } = yield* makeExecutor();261 Effect.succeed(
267 const { calls } = mockTokenFetch(262 new Response(
268 () =>263 JSON.stringify({
269 Effect.succeed(264 access_token: "fresh-access-v2",
270 new Response(265 token_type: "Bearer",
271 JSON.stringify({266 refresh_token: "refresh-v2",
272 access_token: "fresh-access-v2",267 expires_in: 3600,
273 token_type: "Bearer",268 }),
274 refresh_token: "refresh-v2",269 { status: 200, headers: { "content-type": "application/json" } },
275 expires_in: 3600,270 ),
276 }),271 ),
277 { status: 200, headers: { "content-type": "application/json" } },272 );
278 ),273
279 ),274 const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-ok");
280 );275
281 276 yield* executor.openapi.addSpec({
282 const auth = yield* seedExpiredConnection(277 spec: specJson,
283 executor,278 scope: String(scopeId),
284 scopeId,279 namespace: "petstore",
285 "conn-refresh-ok",280 baseUrl,
286 );281 oauth2: auth,
287 282 });
288 yield* executor.openapi.addSpec({283
289 spec: specJson,284 const result = (yield* executor.tools.invoke(
290 scope: scopeId,285 "petstore.items.echoHeaders",
291 namespace: "petstore",286 {},
292 baseUrl,287 autoApprove,
293 oauth2: auth,288 )) as { data: { authorization?: string } | null; error: unknown };
294 });289
295 290 expect(result.error).toBeNull();
296 const result = (yield* executor.tools.invoke(291 // Proves the refresh landed: invoke carried the fresh token,
297 "petstore.items.echoHeaders",292 // not the expired one we seeded.
298 {},293 expect(result.data?.authorization).toBe("Bearer fresh-access-v2");
299 autoApprove,294 expect(calls).toHaveLength(1);
300 )) as { data: { authorization?: string } | null; error: unknown };295 expect(calls[0]!.body.get("grant_type")).toBe("refresh_token");
301 296 expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1");
302 expect(result.error).toBeNull();297
303 // Proves the refresh landed: invoke carried the fresh token,298 // Connection row is patched with the new expiry so the next
304 // not the expired one we seeded.299 // invoke in-window doesn't trip a second refresh.
305 expect(result.data?.authorization).toBe("Bearer fresh-access-v2");300 const conn = yield* executor.connections.get("conn-refresh-ok");
306 expect(calls).toHaveLength(1);301 expect(conn).not.toBeNull();
307 expect(calls[0]!.body.get("grant_type")).toBe("refresh_token");302 expect(conn!.expiresAt).not.toBeNull();
308 expect(calls[0]!.body.get("refresh_token")).toBe("refresh-v1");303 expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000);
309 304 }),
310 // Connection row is patched with the new expiry so the next
311 // invoke in-window doesn't trip a second refresh.
312 const conn = yield* executor.connections.get("conn-refresh-ok");
313 expect(conn).not.toBeNull();
314 expect(conn!.expiresAt).not.toBeNull();
315 expect(conn!.expiresAt!).toBeGreaterThan(Date.now() + 3_000_000);
316 }),
317 );305 );
318 306
319 it.effect(307 it.effect("concurrent invokes with an expired token issue exactly one refresh", () =>
320 "concurrent invokes with an expired token issue exactly one refresh",308 Effect.gen(function* () {
321 () =>309 const { executor, scopeId, baseUrl } = yield* makeExecutor();
322 Effect.gen(function* () {310 const { calls } = mockTokenFetch(() =>
323 const { executor, scopeId, baseUrl } = yield* makeExecutor();311 Effect.succeed(
324 const { calls } = mockTokenFetch(312 new Response(
325 () =>313 JSON.stringify({
326 Effect.succeed(314 access_token: "fresh-access-v2",
327 new Response(315 token_type: "Bearer",
328 JSON.stringify({316 refresh_token: "refresh-v2",
329 access_token: "fresh-access-v2",317 expires_in: 3600,
330 token_type: "Bearer",318 }),
331 refresh_token: "refresh-v2",319 { status: 200, headers: { "content-type": "application/json" } },
332 expires_in: 3600,
333 }),
334 { status: 200, headers: { "content-type": "application/json" } },
335 ),
336 ),
337 );
338
339 const auth = yield* seedExpiredConnection(
340 executor,
341 scopeId,
342 "conn-refresh-concurrent",
343 );
344
345 yield* executor.openapi.addSpec({
346 spec: specJson,
347 scope: scopeId,
348 namespace: "petstore",
349 baseUrl,
350 oauth2: auth,
351 });
352
353 const invokes = yield* Effect.all(
354 [1, 2, 3, 4, 5].map(() =>
355 executor.tools.invoke(
356 "petstore.items.echoHeaders",
357 {},
358 autoApprove,
359 ),
360 ),320 ),
361 { concurrency: "unbounded" },321 ),
362 );322 );
363 323
364 for (const r of invokes) {324 const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-concurrent");
365 const res = r as {325
366 data: { authorization?: string } | null;326 yield* executor.openapi.addSpec({
367 error: unknown;327 spec: specJson,
368 };328 scope: String(scopeId),
369 expect(res.error).toBeNull();329 namespace: "petstore",
370 expect(res.data?.authorization).toBe("Bearer fresh-access-v2");330 baseUrl,
371 }331 oauth2: auth,
372 // Critical assertion: the SDK's dedup collapses every parallel332 });
373 // invoke into one call to the token endpoint. Anything more333
374 // means we're hammering the AS under load.334 const invokes = yield* Effect.all(
375 expect(calls).toHaveLength(1);335 [1, 2, 3, 4, 5].map(() =>
376 }),336 executor.tools.invoke("petstore.items.echoHeaders", {}, autoApprove),
337 ),
338 { concurrency: "unbounded" },
339 );
340
341 for (const r of invokes) {
342 const res = r as {
343 data: { authorization?: string } | null;
344 error: unknown;
345 };
346 expect(res.error).toBeNull();
347 expect(res.data?.authorization).toBe("Bearer fresh-access-v2");
348 }
349 // Critical assertion: the SDK's dedup collapses every parallel
350 // invoke into one call to the token endpoint. Anything more
351 // means we're hammering the AS under load.
352 expect(calls).toHaveLength(1);
353 }),
377 );354 );
378 355
379 it.effect(356 it.effect("invalid_grant from refresh surfaces as ConnectionReauthRequiredError", () =>
380 "invalid_grant from refresh surfaces as ConnectionReauthRequiredError",357 Effect.gen(function* () {
381 () =>358 const { executor, scopeId, baseUrl } = yield* makeExecutor();
382 Effect.gen(function* () {359 mockTokenFetch(() =>
383 const { executor, scopeId, baseUrl } = yield* makeExecutor();360 Effect.succeed(
384 mockTokenFetch(361 new Response(
385 () =>362 JSON.stringify({
386 Effect.succeed(363 error: "invalid_grant",
387 new Response(364 error_description: "Refresh token revoked",
388 JSON.stringify({365 }),
389 error: "invalid_grant",366 { status: 400, headers: { "content-type": "application/json" } },
390 error_description: "Refresh token revoked",367 ),
391 }),368 ),
392 { status: 400, headers: { "content-type": "application/json" } },369 );
393 ),370
394 ),371 const auth = yield* seedExpiredConnection(executor, scopeId, "conn-refresh-dead");
395 );372
396 373 yield* executor.openapi.addSpec({
397 const auth = yield* seedExpiredConnection(374 spec: specJson,
398 executor,375 scope: String(scopeId),
399 scopeId,376 namespace: "petstore",
400 "conn-refresh-dead",377 baseUrl,
401 );378 oauth2: auth,
402 379 });
403 yield* executor.openapi.addSpec({380
404 spec: specJson,381 // Tool invocation currently wraps connection errors in a
405 scope: scopeId,382 // generic Error (see openapi invokeTool), so we assert against
406 namespace: "petstore",383 // the `accessToken` call directly too — that's the surface
407 baseUrl,384 // the UI bridges use to trigger re-auth.
408 oauth2: auth,385 const flipped = yield* executor.connections.accessToken("conn-refresh-dead").pipe(
409 });386 Effect.flip,
410 387 Effect.flatMap((error) =>
411 // Tool invocation currently wraps connection errors in a388 Predicate.isTagged("ConnectionReauthRequiredError")(error)
412 // generic Error (see openapi invokeTool), so we assert against389 ? Effect.succeed(error)
413 // the `accessToken` call directly too — that's the surface390 : Effect.fail(error),
414 // the UI bridges use to trigger re-auth.391 ),
415 const reauthRequired = yield* executor.connections392 );
416 .accessToken("conn-refresh-dead")393 expect(flipped.provider).toBe("openapi:oauth2");
417 .pipe(394 expect(flipped.message).toBe("OAuth refresh failed");
418 Effect.flatMap(() =>395 }),
419 Effect.fail(
420 new OpenApiOauthTestSetupError({
421 message: "Expected refresh to require re-auth",
422 }),
423 ),
424 ),
425 Effect.catchTag("ConnectionReauthRequiredError", Effect.succeed),
426 );
427 expect(reauthRequired.provider).toBe(
428 "openapi:oauth2",
429 );
430 expect(
431 reauthRequired.message,
432 ).toMatch(/invalid_grant|revoked/i);
433 }),
434 );396 );
435});397});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

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

Repository code

Go to file
t