import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { ORCHESTRATION_PROTOCOL_HEADER, ORCHESTRATION_PROTOCOL_VERSION_TEXT, } from "@t3tools/contracts"; import * as Result from "effect/Result"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { FetchHttpClient, type HttpMethod } from "effect/http"; import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient, makeEnvironmentHttpApiUrlBuilder, RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthTimeoutError, type RemoteEnvironmentRequestError, } from "../rpc/http.ts"; export interface EnvironmentHttpAuthHeaders { readonly authorization?: string; readonly dpop?: string; } export function withOrchestrationProtocolHeader( headers: EnvironmentHttpAuthHeaders, ): EnvironmentHttpAuthHeaders & { readonly [ORCHESTRATION_PROTOCOL_HEADER]: typeof ORCHESTRATION_PROTOCOL_VERSION_TEXT; } { return { ...headers, [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT, }; } /** * Cookie-authenticated environments need credentialed mode for cross-origin fetches; bearer/DPoP uses headers. * `FetchHttpClient` reads `RequestInit` from the fiber context per request. */ const withEnvironmentCredentials = ( authorization: PreparedHttpAuthorization | null, request: Effect.Effect, ): Effect.Effect => authorization === null ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })) : request; const buildEnvironmentAuthHeaders = Effect.fn("environmentHttpAuth.buildEnvironmentAuthHeaders")( function* ( authorization: PreparedHttpAuthorization | null, method: HttpMethod.HttpMethod, url: string, signer: Option.Option, ): Effect.fn.Return { if (authorization === null) { return {}; } if (authorization._tag === "Bearer") { return { authorization: `Bearer ${authorization.token}` }; } if (Option.isNone(signer)) { return yield* new RemoteEnvironmentAuthFetchError({ message: "No DPoP signer is available to authorize the environment request.", cause: authorization._tag, }); } const proof = yield* signer.value .createProof({ method, url, accessToken: authorization.accessToken }) .pipe( Effect.mapError( (cause) => new RemoteEnvironmentAuthFetchError({ message: "Could not create the environment request authorization proof.", cause, }), ), ); return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof }; }, ); /** * A DPoP request is T3 Connect work, so its span starts an exported trace that * the environment continues; its local caller's span would leave that trace * without a root. */ export const executeAuthenticatedEnvironmentHttpRequest = < Group extends Parameters[1], A, E, R, >( input: Parameters>[0], ) => input.prepared.httpAuthorization?._tag === "Dpop" ? executeEnvironmentRequest(input).pipe( Effect.withSpan(ENVIRONMENT_REQUEST_SPAN, { root: true }), withRelayClientTracing, ) : executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN)); const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest"; const executeEnvironmentRequest = Effect.fnUntraced(function* < Group extends Parameters[1], A, E, R, >(input: { readonly prepared: PreparedConnection; readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly method: HttpMethod.HttpMethod; /** * The URL to sign, from the group's contract URL builder. It encodes path * params exactly like the request client, so the DPoP proof matches the * URL actually sent. */ readonly url: (urls: ReturnType[Group]) => string; readonly timeoutMs: number; readonly group: Group; readonly request: (input: { readonly client: Effect.Success>>; readonly headers: EnvironmentHttpAuthHeaders; }) => Effect.Effect; /** Some endpoints report rejected credentials in a successful response. */ readonly isUnauthorizedResponse?: (response: NoInfer) => boolean; }): Effect.fn.Return< A, RemoteEnvironmentRequestError, Effect.Services>> | R > { let httpBaseUrl = input.prepared.httpBaseUrl; const requestUrlFor = (baseUrl: string) => input.url(makeEnvironmentHttpApiUrlBuilder(baseUrl)[input.group]); return yield* Effect.gen(function* () { let rejectedAccessToken: string | undefined; for (;;) { let authorization = input.prepared.httpAuthorization; if (authorization?._tag === "Dpop") { const remote = input.remoteAuthorization; if (remote === undefined || Option.isNone(remote)) { return yield* new RemoteEnvironmentAuthFetchError({ message: "No relay authorization service is available for the environment request.", cause: input.prepared.target._tag, }); } const current = yield* remote.value .authorizeDpopHttp({ expectedEnvironmentId: input.prepared.environmentId, ...(rejectedAccessToken === undefined ? {} : { rejectedAccessToken }), }) .pipe( Effect.mapError( (cause) => new RemoteEnvironmentAuthFetchError({ message: "Could not authorize the environment request.", cause, }), ), ); // A learned direct route keeps its own origin; only the token renews. if (input.prepared.target._tag === "RelayConnectionTarget") { httpBaseUrl = current.httpBaseUrl; } authorization = current.httpAuthorization; } const requestUrl = requestUrlFor(httpBaseUrl); const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group); const headers = yield* buildEnvironmentAuthHeaders( authorization, input.method, requestUrl, input.signer, ); const result = yield* executeEnvironmentHttpRequest( requestUrl, input.timeoutMs, withEnvironmentCredentials(authorization, input.request({ client, headers })), ).pipe(Effect.result); if (Result.isFailure(result)) { if ( authorization?._tag === "Dpop" && rejectedAccessToken === undefined && result.failure._tag === "EnvironmentAuthInvalidError" && result.failure.reason === "invalid_credential" ) { rejectedAccessToken = authorization.accessToken; continue; } return yield* result.failure; } if ( authorization?._tag === "Dpop" && input.isUnauthorizedResponse?.(result.success) === true ) { if (rejectedAccessToken === undefined) { rejectedAccessToken = authorization.accessToken; continue; } return yield* new RemoteEnvironmentAuthFetchError({ message: "The environment rejected the renewed session authorization.", cause: result.success, }); } return result.success; } }).pipe( Effect.timeoutOrElse({ duration: input.timeoutMs, orElse: () => Effect.fail( new RemoteEnvironmentAuthTimeoutError(requestUrlFor(httpBaseUrl), input.timeoutMs), ), }), ); });