Skip to content
Source
Sign inSign up

environmentHttpAuth.ts

272 lines9.8 KB

1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import {
4 ORCHESTRATION_PROTOCOL_HEADER,
5 ORCHESTRATION_PROTOCOL_VERSION_TEXT,
6} from "@t3tools/contracts";
7import * as Result from "effect/Result";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
10import { FetchHttpClient, type HttpMethod } from "effect/http";
11​
12import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
13import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts";
14import * as EnvironmentSupervisor from "../connection/supervisor.ts";
15import * as ManagedRelay from "../relay/managedRelay.ts";
16import {
17 executeEnvironmentHttpRequest,
18 makeEnvironmentHttpApiGroupClient,
19 makeEnvironmentHttpApiUrlBuilder,
20 RemoteEnvironmentAuthFetchError,
21 RemoteEnvironmentAuthTimeoutError,
22 type RemoteEnvironmentRequestError,
23} from "../rpc/http.ts";
24​
25export interface EnvironmentHttpAuthHeaders {
26 readonly authorization?: string;
27 readonly dpop?: string;
29​
30export function withOrchestrationProtocolHeader(
31 headers: EnvironmentHttpAuthHeaders,
32): EnvironmentHttpAuthHeaders & {
33 readonly [ORCHESTRATION_PROTOCOL_HEADER]: typeof ORCHESTRATION_PROTOCOL_VERSION_TEXT;
34} {
35 return {
36 ...headers,
37 [ORCHESTRATION_PROTOCOL_HEADER]: ORCHESTRATION_PROTOCOL_VERSION_TEXT,
38 };
40​
41/**
42 * Cookie-authenticated environments need credentialed mode for cross-origin fetches; bearer/DPoP uses headers.
43 * `FetchHttpClient` reads `RequestInit` from the fiber context per request.
44 */
45const withEnvironmentRequestInit = <A, E, R>(
46 authorization: PreparedHttpAuthorization | null,
47 requestInit: RequestInit | undefined,
48 request: Effect.Effect<A, E, R>,
49): Effect.Effect<A, E, R> => {
50 if (authorization !== null && requestInit === undefined) return request;
51 return request.pipe(
52 Effect.provideService(FetchHttpClient.RequestInit, {
53 ...requestInit,
54 ...(authorization === null ? { credentials: "include" as const } : {}),
55 }),
56 );
57};
58​
59const buildEnvironmentAuthHeaders = Effect.fn("environmentHttpAuth.buildEnvironmentAuthHeaders")(
60 function* (
61 authorization: PreparedHttpAuthorization | null,
62 method: HttpMethod.HttpMethod,
63 url: string,
64 signer: Option.Option<ManagedRelay.ManagedRelayDpopSigner["Service"]>,
65 ): Effect.fn.Return<EnvironmentHttpAuthHeaders, RemoteEnvironmentAuthFetchError> {
66 if (authorization === null) {
67 return {};
68 }
69 if (authorization._tag === "Bearer") {
70 return { authorization: `Bearer ${authorization.token}` };
71 }
72 if (Option.isNone(signer)) {
73 return yield* new RemoteEnvironmentAuthFetchError({
74 message: "No DPoP signer is available to authorize the environment request.",
75 cause: authorization._tag,
76 });
77 }
78 const proof = yield* signer.value
79 .createProof({ method, url, accessToken: authorization.accessToken })
80 .pipe(
81 Effect.mapError(
82 (cause) =>
83 new RemoteEnvironmentAuthFetchError({
84 message: "Could not create the environment request authorization proof.",
85 cause,
86 }),
87 ),
88 );
89 return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof };
90 },
91);
92​
93/**
94 * A DPoP request is T3 Connect work, so its span starts an exported trace that
95 * the environment continues; its local caller's span would leave that trace
96 * without a root.
97 */
98export const executeAuthenticatedEnvironmentHttpRequest = <
99 Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1],
100 A,
101 E,
102 R,
103>(
104 input: Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0],
105) =>
106 input.prepared.httpAuthorization?._tag === "Dpop"
107 ? executeEnvironmentRequest(input).pipe(
108 Effect.withSpan(ENVIRONMENT_REQUEST_SPAN, { root: true }),
109 withRelayClientTracing,
110 )
111 : executeEnvironmentRequest(input).pipe(Effect.withSpan(ENVIRONMENT_REQUEST_SPAN));
112​
113/** Sends the request with this client's own credentials on the supervised environment. */
114export const executeConnectedEnvironmentHttpRequest = Effect.fn(
115 "clientRuntime.state.executeConnectedEnvironmentHttpRequest",
116)(function* <Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1], A, E, R>(
117 input: Omit<
118 Parameters<typeof executeEnvironmentRequest<Group, A, E, R>>[0],
119 "prepared" | "signer" | "remoteAuthorization"
120 >,
121) {
122 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
123 const prepared = yield* SubscriptionRef.get(supervisor.prepared);
124 if (Option.isNone(prepared)) {
125 return yield* new RemoteEnvironmentAuthFetchError({
126 message: "The environment is not connected.",
127 cause: supervisor.target._tag,
128 });
129 }
130 return yield* executeAuthenticatedEnvironmentHttpRequest({
131 ...input,
132 prepared: prepared.value,
133 signer: yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner),
134 remoteAuthorization: yield* Effect.serviceOption(
135 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
136 ),
137 });
138});
139​
140const ENVIRONMENT_REQUEST_SPAN = "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest";
141​
142const executeEnvironmentRequest = Effect.fnUntraced(function* <
143 Group extends Parameters<typeof makeEnvironmentHttpApiGroupClient>[1],
144 A,
145 E,
146 R,
147>(input: {
148 readonly prepared: Pick<
149 PreparedConnection,
150 "environmentId" | "httpBaseUrl" | "httpAuthorization" | "target"
151 >;
152 readonly signer: Option.Option<ManagedRelay.ManagedRelayDpopSigner["Service"]>;
153 readonly remoteAuthorization?: Option.Option<
154 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]
155 >;
156 readonly method: HttpMethod.HttpMethod;
157 /**
158 * The URL to sign, from the group's contract URL builder. It encodes path
159 * params exactly like the request client, so the DPoP proof matches the
160 * URL actually sent.
161 */
162 readonly url: (urls: ReturnType<typeof makeEnvironmentHttpApiUrlBuilder>[Group]) => string;
163 readonly timeoutMs: number;
164 /** Extra fetch options, such as refusing redirects for credentialed audio uploads. */
165 readonly requestInit?: RequestInit;
166 readonly group: Group;
167 readonly request: (input: {
168 readonly client: Effect.Success<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>>;
169 readonly headers: EnvironmentHttpAuthHeaders;
170 }) => Effect.Effect<A, E, R>;
171 /** Some endpoints report rejected credentials in a successful response. */
172 readonly isUnauthorizedResponse?: (response: NoInfer<A>) => boolean;
173}): Effect.fn.Return<
174 A,
175 RemoteEnvironmentRequestError,
176 Effect.Services<ReturnType<typeof makeEnvironmentHttpApiGroupClient<Group>>> | R
177> {
178 let httpBaseUrl = input.prepared.httpBaseUrl;
179 const requestUrlFor = (baseUrl: string) =>
180 input.url(makeEnvironmentHttpApiUrlBuilder(baseUrl)[input.group]);
181 return yield* Effect.gen(function* () {
182 let rejectedAccessToken: string | undefined;
183 for (;;) {
184 let authorization = input.prepared.httpAuthorization;
185 if (authorization?._tag === "Dpop") {
186 const remote = input.remoteAuthorization;
187 if (remote === undefined || Option.isNone(remote)) {
188 return yield* new RemoteEnvironmentAuthFetchError({
189 message: "No relay authorization service is available for the environment request.",
190 cause: input.prepared.target._tag,
191 });
192 }
193 const current = yield* remote.value
194 .authorizeDpopHttp({
195 expectedEnvironmentId: input.prepared.environmentId,
196 ...(rejectedAccessToken === undefined ? {} : { rejectedAccessToken }),
197 })
198 .pipe(
199 Effect.mapError(
200 (cause) =>
201 new RemoteEnvironmentAuthFetchError({
202 message: "Could not authorize the environment request.",
203 cause,
204 }),
205 ),
206 );
207 // A learned direct route keeps its own origin; only the token renews.
208 if (input.prepared.target._tag === "RelayConnectionTarget") {
209 httpBaseUrl = current.httpBaseUrl;
210 }
211 authorization = current.httpAuthorization;
212 }
213​
214 const requestUrl = requestUrlFor(httpBaseUrl);
215 const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group);
216 const headers = yield* buildEnvironmentAuthHeaders(
217 authorization,
218 input.method,
219 requestUrl,
220 input.signer,
221 );
222 const result = yield* executeEnvironmentHttpRequest(
223 requestUrl,
224 input.timeoutMs,
225 withEnvironmentRequestInit(
226 authorization,
227 input.requestInit,
228 input.request({ client, headers }),
229 ),
230 ).pipe(Effect.result);
231​
232 if (Result.isFailure(result)) {
233 if (
234 authorization?._tag === "Dpop" &&
235 rejectedAccessToken === undefined &&
236 result.failure._tag === "EnvironmentAuthInvalidError" &&
237 result.failure.reason === "invalid_credential"
238 ) {
239 rejectedAccessToken = authorization.accessToken;
240 continue;
241 }
242 return yield* result.failure;
243 }
244​
245 if (
246 authorization?._tag === "Dpop" &&
247 input.isUnauthorizedResponse?.(result.success) === true
248 ) {
249 if (rejectedAccessToken === undefined) {
250 rejectedAccessToken = authorization.accessToken;
251 continue;
252 }
253 return yield* new RemoteEnvironmentAuthFetchError({
254 message: "The environment rejected the renewed session authorization.",
255 cause: result.success,
256 });
257 }
258 return result.success;
259 }
260 }).pipe(
261 Effect.timeoutOrElse({
262 duration: input.timeoutMs,
263 orElse: () =>
264 Effect.fail(
265 new RemoteEnvironmentAuthTimeoutError({
266 requestUrl: requestUrlFor(httpBaseUrl),
267 timeoutMs: input.timeoutMs,
268 }),
269 ),
270 }),
271 );
272});