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)),
}),
);
});