| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import { |
| 4 | ORCHESTRATION_PROTOCOL_HEADER, |
| 5 | ORCHESTRATION_PROTOCOL_VERSION_TEXT, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Result from "effect/Result"; |
| 8 | import { FetchHttpClient, type HttpMethod } from "effect/http"; |
| 9 | |
| 10 | import { HostedAuthAccess } from "../platform/capabilities.ts"; |
| 11 | import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; |
| 12 | import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; |
| 13 | import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; |
| 14 | import { |
| 15 | executeEnvironmentHttpRequest, |
| 16 | makeEnvironmentHttpApiGroupClient, |
| 17 | makeEnvironmentHttpApiUrlBuilder, |
| 18 | RemoteEnvironmentAuthFetchError, |
| 19 | RemoteEnvironmentAuthTimeoutError, |
| 20 | type RemoteEnvironmentRequestError, |
| 21 | } from "../rpc/http.ts"; |
| 22 | |
| 23 | export interface EnvironmentHttpAuthHeaders { |
| 24 | readonly "x-csrf-token"?: string; |
| 25 | readonly authorization?: string; |
| 26 | readonly dpop?: string; |
| 27 | } |
| 28 | |
| 29 | export function withOrchestrationProtocolHeader( |
| 30 | headers: EnvironmentHttpAuthHeaders, |
| 31 | ): EnvironmentHttpAuthHeaders & { |
| 32 | readonly [ORCHESTRATION_PROTOCOL_HEADER]: typeof ORCHESTRATION_PROTOCOL_VERSION_TEXT; |
| 33 | } { |
| 34 | return { |
| 35 | ...headers, |
| 36 | [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT, |
| 37 | }; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * Primary/local environments with no bearer or DPoP credential authenticate the |
| 42 | * browser via a session cookie. A cross-origin `fetch` does not send cookies by |
| 43 | * default, so those requests must opt into credentialed mode; bearer/DPoP |
| 44 | * connections carry their credential in a header and need no cookies. Applied |
| 45 | * per-request via `FetchHttpClient.RequestInit`, which the fetch client reads |
| 46 | * from the fiber context at request time. |
| 47 | */ |
| 48 | const withEnvironmentCredentials = <A, E, R>( |
| 49 | authorization: PreparedHttpAuthorization | null, |
| 50 | request: Effect.Effect<A, E, R>, |
| 51 | ): Effect.Effect<A, E, R> => |
| 52 | authorization === null |
| 53 | ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })) |
| 54 | : request; |
| 55 | |
| 56 | /** |
| 57 | * Build request-bound headers from the current environment credential: |
| 58 | * - primary/local connections carry no credential, |
| 59 | * - bearer connections send a static `Bearer` token, |
| 60 | * - relay connections send a `DPoP` access token with a freshly signed proof |
| 61 | * bound to this request's method and URL. |
| 62 | * |
| 63 | * The DPoP signer is passed in (not resolved from context) and is only required |
| 64 | * for relay/DPoP connections, so bearer/primary connections work even when no |
| 65 | * signer is available. |
| 66 | */ |
| 67 | const buildEnvironmentAuthHeaders = ( |
| 68 | authorization: PreparedHttpAuthorization | null, |
| 69 | method: HttpMethod.HttpMethod, |
| 70 | url: string, |
| 71 | signer: Option.Option<ManagedRelayDpopSigner["Service"]>, |
| 72 | ): Effect.Effect<EnvironmentHttpAuthHeaders, RemoteEnvironmentAuthFetchError> => |
| 73 | Effect.gen(function* () { |
| 74 | if (authorization === null) { |
| 75 | const hosted = yield* Effect.serviceOption(HostedAuthAccess); |
| 76 | if (Option.isNone(hosted)) return {}; |
| 77 | return yield* hosted.value |
| 78 | .headers(new URL(url).origin) |
| 79 | .pipe(Effect.orElseSucceed(() => ({}))); |
| 80 | } |
| 81 | if (authorization._tag === "Bearer") { |
| 82 | return { authorization: `Bearer ${authorization.token}` }; |
| 83 | } |
| 84 | if (Option.isNone(signer)) { |
| 85 | return yield* new RemoteEnvironmentAuthFetchError({ |
| 86 | message: "No DPoP signer is available to authorize the environment request.", |
| 87 | cause: authorization._tag, |
| 88 | }); |
| 89 | } |
| 90 | const proof = yield* signer.value |
| 91 | .createProof({ method, url, accessToken: authorization.accessToken }) |
| 92 | .pipe( |
| 93 | Effect.mapError( |
| 94 | (cause) => |
| 95 | new RemoteEnvironmentAuthFetchError({ |
| 96 | message: "Could not create the environment request authorization proof.", |
| 97 | cause, |
| 98 | }), |
| 99 | ), |
| 100 | ); |
| 101 | return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof }; |
| 102 | }); |
| 103 | |
| 104 | /** |
| 105 | * Resolve relay credentials at request time without replacing the live socket. |
| 106 | * A rejected credential gets one refresh and retry, with a new request-bound |
| 107 | * proof. Cookie and bearer requests keep their existing authentication behavior. |
| 108 | */ |
| 109 | export const executeAuthenticatedEnvironmentHttpRequest = < |
| 110 | Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1], |
| 111 | A, |
| 112 | E, |
| 113 | R, |
| 114 | >( |
| 115 | input: Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0], |
| 116 | ) => executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN)); |
| 117 | |
| 118 | const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest"; |
| 119 | |
| 120 | const executeEnvironmentRequest = Effect.fnUntraced(function* < |
| 121 | Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1], |
| 122 | A, |
| 123 | E, |
| 124 | R, |
| 125 | >(input: { |
| 126 | readonly prepared: PreparedConnection; |
| 127 | readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>; |
| 128 | readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>; |
| 129 | readonly method: HttpMethod.HttpMethod; |
| 130 | /** |
| 131 | * The URL to sign, from the group's contract URL builder. It encodes path |
| 132 | * params exactly like the request client, so the DPoP proof matches the |
| 133 | * URL actually sent. |
| 134 | */ |
| 135 | readonly url: (urls: ReturnType<typeof makeEnvironmentHttpApiUrlBuilder>[Group]) => string; |
| 136 | readonly timeoutMs: number; |
| 137 | readonly group: Group; |
| 138 | readonly request: (input: { |
| 139 | readonly client: Effect.Success<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>>; |
| 140 | readonly headers: EnvironmentHttpAuthHeaders; |
| 141 | }) => Effect.Effect<A, E, R>; |
| 142 | /** Some endpoints report rejected credentials in a successful response. */ |
| 143 | readonly isUnauthorizedResponse?: (response: NoInfer<A>) => boolean; |
| 144 | }): Effect.fn.Return< |
| 145 | A, |
| 146 | RemoteEnvironmentRequestError, |
| 147 | Effect.Services<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>> | R |
| 148 | > { |
| 149 | let httpBaseUrl = input.prepared.httpBaseUrl; |
| 150 | const requestUrlFor = (baseUrl: string) => |
| 151 | input.url(makeEnvironmentHttpApiUrlBuilder(baseUrl)[input.group]); |
| 152 | return yield* Effect.gen(function* () { |
| 153 | let rejectedAccessToken: string | undefined; |
| 154 | for (;;) { |
| 155 | let authorization = input.prepared.httpAuthorization; |
| 156 | if (authorization?._tag === "Dpop") { |
| 157 | const remote = input.remoteAuthorization; |
| 158 | if (remote === undefined || Option.isNone(remote)) { |
| 159 | return yield* new RemoteEnvironmentAuthFetchError({ |
| 160 | message: "No relay authorization service is available for the environment request.", |
| 161 | cause: input.prepared.target._tag, |
| 162 | }); |
| 163 | } |
| 164 | const current = yield* remote.value |
| 165 | .authorizeDpopHttp({ |
| 166 | expectedEnvironmentId: input.prepared.environmentId, |
| 167 | ...(rejectedAccessToken === undefined ? {} : { rejectedAccessToken }), |
| 168 | }) |
| 169 | .pipe( |
| 170 | Effect.mapError( |
| 171 | (cause) => |
| 172 | new RemoteEnvironmentAuthFetchError({ |
| 173 | message: "Could not authorize the environment request.", |
| 174 | cause, |
| 175 | }), |
| 176 | ), |
| 177 | ); |
| 178 | // A learned direct route keeps its own origin; only the token renews. |
| 179 | if (input.prepared.target._tag === "RelayConnectionTarget") { |
| 180 | httpBaseUrl = current.httpBaseUrl; |
| 181 | } |
| 182 | authorization = current.httpAuthorization; |
| 183 | } |
| 184 | |
| 185 | const requestUrl = requestUrlFor(httpBaseUrl); |
| 186 | const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group); |
| 187 | const headers = yield* buildEnvironmentAuthHeaders( |
| 188 | authorization, |
| 189 | input.method, |
| 190 | requestUrl, |
| 191 | input.signer, |
| 192 | ); |
| 193 | const result = yield* executeEnvironmentHttpRequest( |
| 194 | requestUrl, |
| 195 | input.timeoutMs, |
| 196 | withEnvironmentCredentials(authorization, input.request({ client, headers })), |
| 197 | ).pipe(Effect.result); |
| 198 | |
| 199 | if (Result.isFailure(result)) { |
| 200 | if ( |
| 201 | authorization?._tag === "Dpop" && |
| 202 | rejectedAccessToken === undefined && |
| 203 | result.failure._tag === "EnvironmentAuthInvalidError" && |
| 204 | result.failure.reason === "invalid_credential" |
| 205 | ) { |
| 206 | rejectedAccessToken = authorization.accessToken; |
| 207 | continue; |
| 208 | } |
| 209 | return yield* result.failure; |
| 210 | } |
| 211 | |
| 212 | if ( |
| 213 | authorization?._tag === "Dpop" && |
| 214 | input.isUnauthorizedResponse?.(result.success) === true |
| 215 | ) { |
| 216 | if (rejectedAccessToken === undefined) { |
| 217 | rejectedAccessToken = authorization.accessToken; |
| 218 | continue; |
| 219 | } |
| 220 | return yield* new RemoteEnvironmentAuthFetchError({ |
| 221 | message: "The environment rejected the renewed session authorization.", |
| 222 | cause: result.success, |
| 223 | }); |
| 224 | } |
| 225 | return result.success; |
| 226 | } |
| 227 | }).pipe( |
| 228 | Effect.timeoutOrElse({ |
| 229 | duration: input.timeoutMs, |
| 230 | orElse: () => |
| 231 | Effect.fail( |
| 232 | new RemoteEnvironmentAuthTimeoutError(requestUrlFor(httpBaseUrl), input.timeoutMs), |
| 233 | ), |
| 234 | }), |
| 235 | ); |
| 236 | }); |