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; readonly authorizeDpop: (input: { readonly expectedEnvironmentId: EnvironmentId; readonly obtainBootstrap: Effect.Effect< RelayEnvironmentAuthorization, ConnectionAttemptError >; }) => Effect.Effect; } >()("@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>(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);