1import {
2 type ClientConnectionMethod,
3 EnvironmentId,
4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";
6import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
7import {
8 exchangeRemoteDpopAccessToken,
9 type RemoteEnvironmentAuthError,
10 resolveRemoteDpopWebSocketConnectionUrl,
11 resolveRemoteWebSocketConnectionUrl,
12} from "./remote.ts";
13import {
14 environmentMismatchError,
15 mapManagedRelayError,
16 mapRemoteDpopEnvironmentError,
17 mapRemoteEnvironmentError,
18} from "../connection/errors.ts";
19import {
20 ConnectionBlockedError,
21 ConnectionTransientError,
22 type ConnectionAttemptError,
23} from "../connection/model.ts";
24import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
25import { environmentEndpointUrl } from "../environment/endpoint.ts";
26import * as ClientCapabilities from "../platform/capabilities.ts";
27import * as ManagedRelay from "../relay/managedRelay.ts";
28import * as TokenStore from "./tokenStore.ts";
29import * as Clock from "effect/Clock";
30import * as Context from "effect/Context";
31import * as Effect from "effect/Effect";
32import * as Fiber from "effect/Fiber";
33import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";
35import * as Ref from "effect/Ref";
36import * as Result from "effect/Result";
37import * as Scope from "effect/Scope";
38import * as Semaphore from "effect/Semaphore";
39import * as HttpClient from "effect/http/HttpClient";
40
41import {
42 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
43 type PreparedHttpAuthorization,
44} from "../connection/model.ts";
45
46export interface AuthorizedRemoteEnvironment {
47 readonly environmentId: EnvironmentId;
48 readonly label: string;
49 readonly httpBaseUrl: string;
50 readonly socketUrl: string;
51 readonly httpAuthorization: PreparedHttpAuthorization;
52}
53
54export interface AuthorizedRemoteHttpEnvironment {
55 readonly environmentId: EnvironmentId;
56 readonly label: string;
57 readonly httpBaseUrl: string;
58 readonly httpAuthorization: Extract<PreparedHttpAuthorization, { readonly _tag: "Dpop" }>;
59}
60
61export class RemoteEnvironmentAuthorization extends Context.Service<
62 RemoteEnvironmentAuthorization,
63 {
64 readonly authorizeBearer: (input: {
65 readonly expectedEnvironmentId: EnvironmentId;
66 readonly httpBaseUrl: string;
67 readonly wsBaseUrl: string;
68 readonly bearerToken: string;
69 readonly connectionMethod: ClientConnectionMethod;
70 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
71 readonly authorizeDpop: (input: {
72 readonly expectedEnvironmentId: EnvironmentId;
73 /**
74 * Connect to this origin instead of the T3 Connect tunnel. The access
75 * token is not bound to an origin, and each proof is minted for the URL
76 * it signs, so a token from the relay also works on the environment's
77 * LAN or tailnet address.
78 */
79 readonly directEndpoint?: { readonly httpBaseUrl: string; readonly wsBaseUrl: string };
80 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
81 readonly authorizeDpopHttp: (input: {
82 readonly expectedEnvironmentId: EnvironmentId;
83 readonly rejectedAccessToken?: string;
84 }) => Effect.Effect<AuthorizedRemoteHttpEnvironment, ConnectionAttemptError>;
85 }
86>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
87
88const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
89const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000;
90const DPOP_AUTHORIZATION_TIMEOUT_MS = 30_000;
91
92function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
93 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
94 ? error
95 : mapRemoteDpopEnvironmentError(error);
96}
97
98const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (
99 httpBaseUrl: string,
100 connectionMethod: ClientConnectionMethod,
101) {
102 return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe(
103 Effect.mapError((error) => mapRemoteEnvironmentError(error, connectionMethod)),
104 );
105});
106
107export const make = Effect.gen(function* () {
108 const serviceScope = yield* Scope.Scope;
109 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
110 const relay = yield* ManagedRelay.ManagedRelayClient;
111 const cloudSession = yield* ClientCapabilities.CloudSession;
112 const deviceIdentity = yield* ClientCapabilities.RelayDeviceIdentity;
113 const presentation = yield* ClientCapabilities.ClientPresentation;
114 const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;
115 const httpClient = yield* HttpClient.HttpClient;
116 const tokenLock = yield* Semaphore.make(1);
117 const tokenOwners = new Map<
118 EnvironmentId,
119 { readonly accessToken: string; readonly identity: ClientCapabilities.CloudSessionIdentity }
120 >();
121 const pendingTokens = new Map<
122 EnvironmentId,
123 {
124 readonly id: object;
125 readonly identity: ClientCapabilities.CloudSessionIdentity;
126 readonly thumbprint: string;
127 readonly token: Effect.Effect<TokenStore.RemoteDpopAccessToken, ConnectionAttemptError>;
128 }
129 >();
130 const bearerDescriptors = yield* Ref.make<
131 ReadonlyMap<
132 EnvironmentId,
133 {
134 readonly httpBaseUrl: string;
135 readonly descriptor: ExecutionEnvironmentDescriptor;
136 readonly validatedAtEpochMs: number;
137 }
138 >
139 >(new Map());
140
141 /**
142 * Confirms a direct address still serves the expected environment before a
143 * credential is sent there. A saved LAN address can belong to another
144 * machine on a different network.
145 */
146 const verifyDirectEndpoint = Effect.fn("clientRuntime.connection.remote.verifyDirectEndpoint")(
147 function* (input: {
148 readonly expectedEnvironmentId: EnvironmentId;
149 readonly httpBaseUrl: string;
150 readonly connectionMethod: ClientConnectionMethod;
151 }) {
152 const now = yield* Clock.currentTimeMillis;
153 const cachedDescriptor = (yield* Ref.get(bearerDescriptors)).get(input.expectedEnvironmentId);
154 const canReuseDescriptor =
155 cachedDescriptor?.httpBaseUrl === input.httpBaseUrl &&
156 cachedDescriptor.validatedAtEpochMs + BEARER_DESCRIPTOR_CACHE_TTL_MS > now;
157 const descriptor = canReuseDescriptor
158 ? cachedDescriptor.descriptor
159 : yield* fetchDescriptor(input.httpBaseUrl, input.connectionMethod).pipe(
160 Effect.provideService(HttpClient.HttpClient, httpClient),
161 );
162 if (descriptor.environmentId !== input.expectedEnvironmentId) {
163 return yield* environmentMismatchError({
164 expected: input.expectedEnvironmentId,
165 actual: descriptor.environmentId,
166 });
167 }
168 if (!canReuseDescriptor) {
169 yield* Ref.update(bearerDescriptors, (current) => {
170 const next = new Map(current);
171 next.set(input.expectedEnvironmentId, {
172 httpBaseUrl: input.httpBaseUrl,
173 descriptor,
174 validatedAtEpochMs: now,
175 });
176 return next;
177 });
178 }
179 return descriptor;
180 },
181 );
182
183 const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")(
184 function* (input: {
185 readonly expectedEnvironmentId: Parameters<
186 RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]
187 >[0]["expectedEnvironmentId"];
188 readonly httpBaseUrl: string;
189 readonly wsBaseUrl: string;
190 readonly bearerToken: string;
191 readonly connectionMethod: ClientConnectionMethod;
192 }) {
193 const descriptor = yield* verifyDirectEndpoint(input);
194 const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
195 wsBaseUrl: input.wsBaseUrl,
196 httpBaseUrl: input.httpBaseUrl,
197 bearerToken: input.bearerToken,
198 clientMetadata: presentation.metadata,
199 }).pipe(
200 Effect.mapError(mapRemoteEnvironmentError),
201 Effect.provideService(HttpClient.HttpClient, httpClient),
202 );
203 return {
204 environmentId: descriptor.environmentId,
205 label: descriptor.label,
206 httpBaseUrl: input.httpBaseUrl,
207 socketUrl,
208 httpAuthorization: {
209 _tag: "Bearer" as const,
210 token: input.bearerToken,
211 },
212 };
213 },
214 );
215
216 const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")(
217 function* (
218 token: TokenStore.RemoteDpopAccessToken,
219 timeoutMs?: number,
220 endpoint: { readonly httpBaseUrl: string; readonly wsBaseUrl: string } = token.endpoint,
221 ) {
222 const ticketProof = yield* signer
223 .createProof({
224 method: "POST",
225 url: environmentEndpointUrl(endpoint.httpBaseUrl, "/api/auth/websocket-ticket"),
226 accessToken: token.accessToken,
227 })
228 .pipe(
229 Effect.mapError(
230 () =>
231 new ConnectionBlockedError({
232 reason: "configuration",
233 detail: "Could not create the websocket authorization proof.",
234 }),
235 ),
236 );
237 return yield* resolveRemoteDpopWebSocketConnectionUrl({
238 wsBaseUrl: endpoint.wsBaseUrl,
239 httpBaseUrl: endpoint.httpBaseUrl,
240 accessToken: token.accessToken,
241 dpopProof: ticketProof,
242 clientMetadata: presentation.metadata,
243 ...(timeoutMs === undefined ? {} : { timeoutMs }),
244 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
245 },
246 );
247
248 const sessionChanged = () =>
249 new ConnectionBlockedError({
250 reason: "authentication",
251 detail: "Your cloud sign-in changed. Sign in again to authorize the environment.",
252 });
253
254 const assertSession = Effect.fnUntraced(function* (
255 identity: ClientCapabilities.CloudSessionIdentity,
256 ) {
257 const current = yield* cloudSession.identity;
258 if (Option.isNone(current) || current.value !== identity) {
259 return yield* sessionChanged();
260 }
261 });
262
263 // Called under tokenLock so a late rejection cannot remove a newer credential.
264 const removeRejectedToken = Effect.fnUntraced(function* (
265 environmentId: EnvironmentId,
266 accessToken: string,
267 ) {
268 const cached = yield* tokenStore.get(environmentId);
269 if (Option.isSome(cached) && cached.value.accessToken === accessToken) {
270 yield* tokenStore.remove(environmentId);
271 tokenOwners.delete(environmentId);
272 }
273 });
274
275 const obtainBootstrap = Effect.fn("relay.connection.bootstrap.obtain")(function* (
276 environmentId: EnvironmentId,
277 identity: ClientCapabilities.CloudSessionIdentity,
278 ) {
279 yield* assertSession(identity);
280 const clerkToken = yield* cloudSession.clerkToken.pipe(
281 Effect.withSpan("relay.connection.cloudSessionToken.resolve"),
282 );
283 const deviceId = yield* deviceIdentity.deviceId.pipe(
284 Effect.withSpan("relay.connection.deviceIdentity.resolve"),
285 );
286 yield* assertSession(identity);
287 const connected = yield* relay
288 .connectEnvironment({
289 clerkToken,
290 scopes: [RelayEnvironmentConnectScope],
291 environmentId,
292 ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}),
293 })
294 .pipe(Effect.mapError(mapManagedRelayError));
295 if (connected.environmentId !== environmentId) {
296 return yield* environmentMismatchError({
297 expected: environmentId,
298 actual: connected.environmentId,
299 });
300 }
301 yield* assertSession(identity);
302 return connected;
303 });
304
305 const exchangeDpopToken = Effect.fn("clientRuntime.connection.remote.exchangeDpopToken")(
306 function* (
307 environmentId: EnvironmentId,
308 thumbprint: string,
309 identity: ClientCapabilities.CloudSessionIdentity,
310 ) {
311 const bootstrap = yield* obtainBootstrap(environmentId, identity);
312 const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe(
313 Effect.provideService(HttpClient.HttpClient, httpClient),
314 Effect.withSpan("environment.authorization.descriptor"),
315 );
316 if (descriptor.environmentId !== environmentId) {
317 return yield* environmentMismatchError({
318 expected: environmentId,
319 actual: descriptor.environmentId,
320 });
321 }
322 const bootstrapProof = yield* signer
323 .createProof({
324 method: "POST",
325 url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"),
326 })
327 .pipe(
328 Effect.mapError(
329 () =>
330 new ConnectionBlockedError({
331 reason: "configuration",
332 detail: "Could not create the environment authorization proof.",
333 }),
334 ),
335 );
336 yield* assertSession(identity);
337 const access = yield* exchangeRemoteDpopAccessToken({
338 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,
339 credential: bootstrap.credential,
340 dpopProof: bootstrapProof,
341 clientMetadata: presentation.metadata,
342 }).pipe(
343 Effect.mapError(mapRemoteDpopEnvironmentError),
344 Effect.provideService(HttpClient.HttpClient, httpClient),
345 Effect.withSpan("environment.authorization.accessToken.exchange"),
346 );
347 const issuedAt = yield* Clock.currentTimeMillis;
348 return new TokenStore.RemoteDpopAccessToken({
349 environmentId: descriptor.environmentId,
350 accountId: identity.accountId,
351 label: descriptor.label,
352 endpoint: bootstrap.endpoint,
353 accessToken: access.access_token,
354 expiresAtEpochMs: issuedAt + access.expires_in * 1_000,
355 dpopThumbprint: thumbprint,
356 });
357 },
358 );
359
360 const getDpopToken = Effect.fn("clientRuntime.connection.remote.getDpopToken")(function* (
361 input: Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpopHttp"]>[0],
362 ) {
363 const session = yield* cloudSession.identity;
364 if (Option.isNone(session)) {
365 return yield* sessionChanged();
366 }
367 const identity = session.value;
368 const thumbprint = yield* signer.thumbprint.pipe(
369 Effect.mapError(
370 () =>
371 new ConnectionBlockedError({
372 reason: "configuration",
373 detail: "Could not load the environment authorization key.",
374 }),
375 ),
376 Effect.withSpan("environment.authorization.dpopKey.resolve"),
377 );
378 const selected = yield* tokenLock.withPermits(1)(
379 Effect.gen(function* () {
380 yield* assertSession(identity);
381 const now = yield* Clock.currentTimeMillis;
382 const cached = yield* tokenStore
383 .get(input.expectedEnvironmentId)
384 .pipe(Effect.withSpan("environment.authorization.accessToken.cache"));
385 const owner = tokenOwners.get(input.expectedEnvironmentId);
386 if (
387 Option.isSome(cached) &&
388 cached.value.environmentId === input.expectedEnvironmentId &&
389 cached.value.accountId === identity.accountId &&
390 cached.value.dpopThumbprint === thumbprint &&
391 cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS &&
392 cached.value.accessToken !== input.rejectedAccessToken &&
393 (owner?.accessToken !== cached.value.accessToken || owner.identity === identity)
394 ) {
395 tokenOwners.set(input.expectedEnvironmentId, {
396 accessToken: cached.value.accessToken,
397 identity,
398 });
399 return { token: Effect.succeed(cached.value), fromCache: true };
400 }
401 if (input.rejectedAccessToken !== undefined) {
402 yield* removeRejectedToken(input.expectedEnvironmentId, input.rejectedAccessToken);
403 }
404 const pending = pendingTokens.get(input.expectedEnvironmentId);
405 if (pending?.identity === identity && pending.thumbprint === thumbprint) {
406 return { token: pending.token, fromCache: false };
407 }
408
409 const id = {};
410 const fiber = yield* exchangeDpopToken(
411 input.expectedEnvironmentId,
412 thumbprint,
413 identity,
414 ).pipe(
415 Effect.flatMap((token) =>
416 tokenLock.withPermits(1)(
417 Effect.gen(function* () {
418 yield* assertSession(identity);
419 yield* tokenStore
420 .put(token)
421 .pipe(Effect.withSpan("environment.authorization.accessToken.persist"));
422 const current = yield* cloudSession.identity;
423 if (Option.isNone(current) || current.value !== identity) {
424 yield* removeRejectedToken(input.expectedEnvironmentId, token.accessToken);
425 return yield* sessionChanged();
426 }
427 tokenOwners.set(input.expectedEnvironmentId, {
428 accessToken: token.accessToken,
429 identity,
430 });
431 if (pendingTokens.get(input.expectedEnvironmentId)?.id === id) {
432 pendingTokens.delete(input.expectedEnvironmentId);
433 }
434 return token;
435 }),
436 ),
437 ),
438 Effect.timeoutOrElse({
439 duration: DPOP_AUTHORIZATION_TIMEOUT_MS,
440 orElse: () =>
441 Effect.fail(
442 new ConnectionTransientError({
443 reason: "timeout",
444 detail: "Timed out renewing the environment credential.",
445 }),
446 ),
447 }),
448 Effect.ensuring(
449 tokenLock.withPermits(1)(
450 Effect.sync(() => {
451 if (pendingTokens.get(input.expectedEnvironmentId)?.id === id) {
452 pendingTokens.delete(input.expectedEnvironmentId);
453 }
454 }),
455 ),
456 ),
457 // One caller's timeout must not cancel renewal for other HTTP requests.
458 Effect.forkIn(serviceScope),
459 );
460 const token = Fiber.join(fiber);
461 pendingTokens.set(input.expectedEnvironmentId, { id, identity, thumbprint, token });
462 return { token, fromCache: false };
463 }),
464 );
465 yield* Effect.annotateCurrentSpan({
466 "connection.remote_token_cache": selected.fromCache ? "hit" : "miss",
467 });
468 const token = yield* selected.token;
469 yield* assertSession(identity);
470 return { token, fromCache: selected.fromCache, identity };
471 });
472
473 const httpAuthorization = (
474 token: TokenStore.RemoteDpopAccessToken,
475 httpBaseUrl = token.endpoint.httpBaseUrl,
476 ): AuthorizedRemoteHttpEnvironment => ({
477 environmentId: token.environmentId,
478 label: token.label,
479 httpBaseUrl,
480 httpAuthorization: {
481 _tag: "Dpop",
482 accessToken: token.accessToken,
483 expiresAtEpochMs: token.expiresAtEpochMs,
484 },
485 });
486
487 const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(function* (
488 input: Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]>[0],
489 ) {
490 const endpoint = input.directEndpoint;
491 if (endpoint !== undefined) {
492 yield* verifyDirectEndpoint({
493 expectedEnvironmentId: input.expectedEnvironmentId,
494 httpBaseUrl: endpoint.httpBaseUrl,
495 connectionMethod: "direct",
496 });
497 }
498 const authorized = (token: TokenStore.RemoteDpopAccessToken, socketUrl: string) => ({
499 ...httpAuthorization(token, endpoint?.httpBaseUrl),
500 socketUrl,
501 });
502 let selected = yield* getDpopToken(input);
503 if (selected.fromCache) {
504 const cachedSocket = yield* createDpopSocketUrl(
505 selected.token,
506 CACHED_ENDPOINT_SOCKET_TIMEOUT_MS,
507 endpoint,
508 ).pipe(Effect.result);
509 if (Result.isSuccess(cachedSocket)) {
510 yield* assertSession(selected.identity);
511 return authorized(selected.token, cachedSocket.success);
512 }
513 if (cachedSocket.failure._tag === "ConnectionBlockedError") {
514 return yield* mapDpopSocketError(cachedSocket.failure);
515 }
516 // An unreachable direct address says nothing about the token, so keep
517 // it for the T3 Connect route rather than forcing a relay round trip.
518 // A rejected token falls through and is replaced like on the tunnel.
519 if (endpoint !== undefined && cachedSocket.failure._tag !== "EnvironmentAuthInvalidError") {
520 return yield* mapDpopSocketError(cachedSocket.failure);
521 }
522 selected = yield* getDpopToken({
523 ...input,
524 rejectedAccessToken: selected.token.accessToken,
525 });
526 }
527 const socket = yield* createDpopSocketUrl(selected.token, undefined, endpoint).pipe(
528 Effect.result,
529 );
530 if (Result.isFailure(socket)) {
531 // A direct address that refuses the socket says nothing about the token.
532 if (endpoint === undefined || socket.failure._tag === "ConnectionBlockedError") {
533 yield* tokenLock.withPermits(1)(
534 removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken),
535 );
536 }
537 return yield* mapDpopSocketError(socket.failure);
538 }
539 yield* assertSession(selected.identity);
540 return authorized(selected.token, socket.success);
541 });
542
543 return RemoteEnvironmentAuthorization.of({
544 authorizeBearer,
545 authorizeDpop: (input) =>
546 authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")),
547 authorizeDpopHttp: (input) =>
548 getDpopToken(input).pipe(
549 Effect.map(({ token }) => httpAuthorization(token)),
550 Effect.withSpan("environment.authorization.http"),
551 ),
552 });
553});
554
555export const layer = Layer.effect(RemoteEnvironmentAuthorization, make);