302 lines · 11.5 KB · TypeScript
RawDownload
import { EnvironmentId } from "@t3tools/contracts";import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";import {  exchangeRemoteDpopAccessToken,  type RemoteEnvironmentAuthError,  resolveRemoteDpopWebSocketConnectionUrl,  resolveRemoteWebSocketConnectionUrl,} from "./remote.ts";import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts";import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts";import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";import { environmentEndpointUrl } from "../environment/endpoint.ts";import * as ClientCapabilities from "../platform/capabilities.ts";import * as ManagedRelay from "../relay/managedRelay.ts";import * as TokenStore from "./tokenStore.ts";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Result from "effect/Result";import * as HttpClient from "effect/unstable/http/HttpClient"; import type { PreparedHttpAuthorization } from "../connection/model.ts"; export interface RelayEnvironmentAuthorization {  readonly environmentId: EnvironmentId;  readonly endpoint: RelayManagedEndpoint;  readonly credential: string;} export interface AuthorizedRemoteEnvironment {  readonly environmentId: EnvironmentId;  readonly label: string;  readonly httpBaseUrl: string;  readonly socketUrl: string;  readonly httpAuthorization: PreparedHttpAuthorization;} export class RemoteEnvironmentAuthorization extends Context.Service<  RemoteEnvironmentAuthorization,  {    readonly authorizeBearer: (input: {      readonly expectedEnvironmentId: EnvironmentId;      readonly httpBaseUrl: string;      readonly wsBaseUrl: string;      readonly bearerToken: string;    }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;    readonly authorizeDpop: (input: {      readonly expectedEnvironmentId: EnvironmentId;      readonly obtainBootstrap: Effect.Effect<        RelayEnvironmentAuthorization,        ConnectionAttemptError      >;    }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;  }>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000;const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {  return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"    ? error    : mapRemoteEnvironmentError(error);} const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (  httpBaseUrl: string,) {  return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe(    Effect.mapError(mapRemoteEnvironmentError),  );}); export const make = Effect.gen(function* () {  const signer = yield* ManagedRelay.ManagedRelayDpopSigner;  const presentation = yield* ClientCapabilities.ClientPresentation;  const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;  const httpClient = yield* HttpClient.HttpClient;  const cachedEndpointFailures = yield* Ref.make<ReadonlyMap<string, number>>(new Map());   const resetCachedEndpointFailures = (environmentId: string) =>    Ref.update(cachedEndpointFailures, (current) => {      if (!current.has(environmentId)) {        return current;      }      const next = new Map(current);      next.delete(environmentId);      return next;    });   const recordCachedEndpointFailure = (environmentId: string) =>    Ref.modify(cachedEndpointFailures, (current) => {      const failureCount = (current.get(environmentId) ?? 0) + 1;      const next = new Map(current);      next.set(environmentId, failureCount);      return [failureCount, next] as const;    });   const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")(    function* (input: {      readonly expectedEnvironmentId: Parameters<        RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]      >[0]["expectedEnvironmentId"];      readonly httpBaseUrl: string;      readonly wsBaseUrl: string;      readonly bearerToken: string;    }) {      const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe(        Effect.provideService(HttpClient.HttpClient, httpClient),      );      if (descriptor.environmentId !== input.expectedEnvironmentId) {        return yield* environmentMismatchError({          expected: input.expectedEnvironmentId,          actual: descriptor.environmentId,        });      }      const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({        wsBaseUrl: input.wsBaseUrl,        httpBaseUrl: input.httpBaseUrl,        bearerToken: input.bearerToken,      }).pipe(        Effect.mapError(mapRemoteEnvironmentError),        Effect.provideService(HttpClient.HttpClient, httpClient),      );      return {        environmentId: descriptor.environmentId,        label: descriptor.label,        httpBaseUrl: input.httpBaseUrl,        socketUrl,        httpAuthorization: {          _tag: "Bearer" as const,          token: input.bearerToken,        },      };    },  );   const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")(    function* (token: TokenStore.RemoteDpopAccessToken) {      const ticketProof = yield* signer        .createProof({          method: "POST",          url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"),          accessToken: token.accessToken,        })        .pipe(          Effect.mapError(            () =>              new ConnectionBlockedError({                reason: "configuration",                detail: "Could not create the websocket authorization proof.",              }),          ),        );      return yield* resolveRemoteDpopWebSocketConnectionUrl({        wsBaseUrl: token.endpoint.wsBaseUrl,        httpBaseUrl: token.endpoint.httpBaseUrl,        accessToken: token.accessToken,        dpopProof: ticketProof,      }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));    },  );   const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(    function* (input: {      readonly expectedEnvironmentId: Parameters<        RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]      >[0]["expectedEnvironmentId"];      readonly obtainBootstrap: Parameters<        RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]      >[0]["obtainBootstrap"];    }) {      const thumbprint = yield* signer.thumbprint.pipe(        Effect.mapError(          () =>            new ConnectionBlockedError({              reason: "configuration",              detail: "Could not load the environment authorization key.",            }),        ),        Effect.withSpan("environment.authorization.dpopKey.resolve"),      );      const now = yield* Clock.currentTimeMillis;      const cached = yield* tokenStore        .get(input.expectedEnvironmentId)        .pipe(Effect.withSpan("environment.authorization.accessToken.cache"));      if (        Option.isSome(cached) &&        cached.value.environmentId === input.expectedEnvironmentId &&        cached.value.dpopThumbprint === thumbprint &&        cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS      ) {        yield* Effect.annotateCurrentSpan({          "connection.remote_token_cache": "hit",        });        const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result);        if (Result.isSuccess(cachedSocket)) {          yield* resetCachedEndpointFailures(input.expectedEnvironmentId);          return {            environmentId: cached.value.environmentId,            label: cached.value.label,            httpBaseUrl: cached.value.endpoint.httpBaseUrl,            socketUrl: cachedSocket.success,            httpAuthorization: {              _tag: "Dpop" as const,              accessToken: cached.value.accessToken,            },          };        }        if (cachedSocket.failure._tag === "ConnectionBlockedError") {          return yield* mapDpopSocketError(cachedSocket.failure);        }        const mappedFailure = mapDpopSocketError(cachedSocket.failure);        if (mappedFailure._tag === "ConnectionTransientError") {          const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId);          if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) {            return yield* mappedFailure;          }        }        yield* tokenStore          .remove(input.expectedEnvironmentId)          .pipe(Effect.withSpan("environment.authorization.accessToken.remove"));        yield* resetCachedEndpointFailures(input.expectedEnvironmentId);      }       yield* resetCachedEndpointFailures(input.expectedEnvironmentId);      yield* Effect.annotateCurrentSpan({        "connection.remote_token_cache": "miss",      });      const bootstrap = yield* input.obtainBootstrap;      const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe(        Effect.provideService(HttpClient.HttpClient, httpClient),        Effect.withSpan("environment.authorization.descriptor"),      );      if (descriptor.environmentId !== input.expectedEnvironmentId) {        return yield* environmentMismatchError({          expected: input.expectedEnvironmentId,          actual: descriptor.environmentId,        });      }      const bootstrapProof = yield* signer        .createProof({          method: "POST",          url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"),        })        .pipe(          Effect.mapError(            () =>              new ConnectionBlockedError({                reason: "configuration",                detail: "Could not create the environment authorization proof.",              }),          ),        );      const access = yield* exchangeRemoteDpopAccessToken({        httpBaseUrl: bootstrap.endpoint.httpBaseUrl,        credential: bootstrap.credential,        dpopProof: bootstrapProof,        scopes: presentation.scopes,        clientMetadata: presentation.metadata,      }).pipe(        Effect.mapError(mapRemoteEnvironmentError),        Effect.provideService(HttpClient.HttpClient, httpClient),        Effect.withSpan("environment.authorization.accessToken.exchange"),      );      const issuedAt = yield* Clock.currentTimeMillis;      const token = new TokenStore.RemoteDpopAccessToken({        environmentId: descriptor.environmentId,        label: descriptor.label,        endpoint: bootstrap.endpoint,        accessToken: access.access_token,        expiresAtEpochMs: issuedAt + access.expires_in * 1_000,        dpopThumbprint: thumbprint,      });      const socketUrl = yield* createDpopSocketUrl(token).pipe(Effect.mapError(mapDpopSocketError));      yield* tokenStore        .put(token)        .pipe(Effect.withSpan("environment.authorization.accessToken.persist"));      return {        environmentId: descriptor.environmentId,        label: descriptor.label,        httpBaseUrl: bootstrap.endpoint.httpBaseUrl,        socketUrl,        httpAuthorization: {          _tag: "Dpop" as const,          accessToken: token.accessToken,        },      };    },  );   return RemoteEnvironmentAuthorization.of({    authorizeBearer,    authorizeDpop: (input) =>      authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")),  });}); export const layer = Layer.effect(RemoteEnvironmentAuthorization, make);