import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { FetchHttpClient, type HttpClient, type HttpMethod } from "effect/unstable/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, makeEnvironmentHttpApiClient, RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthTimeoutError, type RemoteEnvironmentRequestError, } from "../rpc/http.ts"; export interface EnvironmentHttpAuthHeaders { readonly authorization?: string; readonly dpop?: string; } /** * Primary/local environments with no bearer or DPoP credential authenticate the * browser via a session cookie. A cross-origin `fetch` does not send cookies by * default, so those requests must opt into credentialed mode; bearer/DPoP * connections carry their credential in a header and need no cookies. Applied * per-request via `FetchHttpClient.RequestInit`, which the fetch client reads * from the fiber context at request time. */ const withEnvironmentCredentials = ( authorization: PreparedHttpAuthorization | null, request: Effect.Effect, ): Effect.Effect => authorization === null ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })) : request; /** * Build request-bound headers from the current environment credential: * - primary/local connections carry no credential, * - bearer connections send a static `Bearer` token, * - relay connections send a `DPoP` access token with a freshly signed proof * bound to this request's method and URL. * * The DPoP signer is passed in (not resolved from context) and is only required * for relay/DPoP connections, so bearer/primary connections work even when no * signer is available. */ const buildEnvironmentAuthHeaders = ( authorization: PreparedHttpAuthorization | null, method: HttpMethod.HttpMethod, url: string, signer: Option.Option, ): Effect.Effect => Effect.gen(function* () { 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 }; }); /** * Resolve relay credentials at request time without replacing the live socket. * A rejected credential gets one refresh and retry, with a new request-bound * proof. Cookie and bearer requests keep their existing authentication behavior. */ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest", )(function* (input: { readonly prepared: PreparedConnection; readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly method: HttpMethod.HttpMethod; readonly url: (httpBaseUrl: string) => string; readonly timeoutMs: number; 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 { let httpBaseUrl = input.prepared.httpBaseUrl; 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, }), ), ); httpBaseUrl = current.httpBaseUrl; authorization = current.httpAuthorization; } const requestUrl = input.url(httpBaseUrl); const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); 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(input.url(httpBaseUrl), input.timeoutMs)), }), ); });