Skip to content
Source
Sign inSign up
Open History to see the last commit

service.ts

640 lines25.2 KB

1import {
2 type ClientConnectionMethod,
3 EnvironmentId,
4 type ExecutionEnvironmentDescriptor,
5} from "@t3tools/contracts";
6import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
8import {
9 exchangeRemoteDpopAccessToken,
10 type RemoteEnvironmentAuthError,
11 resolveRemoteDpopWebSocketConnectionUrl,
12 resolveRemoteWebSocketConnectionUrl,
13} from "./remote.ts";
14import {
15 environmentMismatchError,
16 mapManagedRelayError,
17 mapRemoteDpopEnvironmentError,
18 mapRemoteEnvironmentError,
19} from "../connection/errors.ts";
20import {
21 ConnectionBlockedError,
22 ConnectionTransientError,
23 type ConnectionAttemptError,
24} from "../connection/model.ts";
25import { isNearbyAddress } from "../connection/routes.ts";
26import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts";
27import { environmentEndpointUrl } from "../environment/endpoint.ts";
28import * as ClientCapabilities from "../platform/capabilities.ts";
29import * as ManagedRelay from "../relay/managedRelay.ts";
30import * as TokenStore from "./tokenStore.ts";
31import * as Clock from "effect/Clock";
32import * as Context from "effect/Context";
33import * as Effect from "effect/Effect";
34import * as Fiber from "effect/Fiber";
35import * as Layer from "effect/Layer";
36import * as Option from "effect/Option";
37import * as Result from "effect/Result";
38import * as Scope from "effect/Scope";
39import * as Semaphore from "effect/Semaphore";
40import * as HttpClient from "effect/http/HttpClient";
41​
42import {
43 DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS,
44 type PreparedHttpAuthorization,
45} from "../connection/model.ts";
46​
47export interface AuthorizedRemoteEnvironment {
48 readonly environmentId: EnvironmentId;
49 readonly label: string;
50 readonly httpBaseUrl: string;
51 readonly socketUrl: string;
52 readonly httpAuthorization: PreparedHttpAuthorization;
53 readonly descriptor?: ExecutionEnvironmentDescriptor;
55​
56export interface AuthorizedRemoteHttpEnvironment {
57 readonly environmentId: EnvironmentId;
58 readonly label: string;
59 readonly httpBaseUrl: string;
60 readonly httpAuthorization: Extract<PreparedHttpAuthorization, { readonly _tag: "Dpop" }>;
62​
63export class RemoteEnvironmentAuthorization extends Context.Service<
64 RemoteEnvironmentAuthorization,
65 {
66 readonly authorizeBearer: (input: {
67 readonly expectedEnvironmentId: EnvironmentId;
68 readonly httpBaseUrl: string;
69 readonly wsBaseUrl: string;
70 readonly bearerToken: string;
71 readonly connectionMethod: ClientConnectionMethod;
72 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
73 readonly authorizeDpop: (input: {
74 readonly expectedEnvironmentId: EnvironmentId;
75 /**
76 * The access token is not bound to an origin, and each proof is minted
77 * for the URL it signs, so a token from the relay also works on the
78 * environment's LAN or tailnet address.
79 */
80 readonly directEndpoint?: { readonly httpBaseUrl: string; readonly wsBaseUrl: string };
81 }) => Effect.Effect<AuthorizedRemoteEnvironment, ConnectionAttemptError>;
82 readonly authorizeDpopHttp: (input: {
83 readonly expectedEnvironmentId: EnvironmentId;
84 readonly rejectedAccessToken?: string;
85 }) => Effect.Effect<AuthorizedRemoteHttpEnvironment, ConnectionAttemptError>;
86 /**
87 * Lets the next direct authorization of this address skip its own descriptor
88 * read. Kept only where the address reaches the same machine on every network.
89 */
90 readonly rememberCheckedDescriptor: (
91 httpBaseUrl: string,
92 descriptor: Option.Option<ExecutionEnvironmentDescriptor>,
93 keepsItsHost: boolean,
94 ) => Effect.Effect<void>;
95 /**
96 * Renews a live socket's token before it expires, so the server moves the
97 * socket to the new session instead of closing it. Runs until interrupted.
98 */
99 readonly renewDpopBeforeExpiry: (input: {
100 readonly expectedEnvironmentId: EnvironmentId;
101 readonly expiresAtEpochMs: number;
102 }) => Effect.Effect<void, ConnectionBlockedError>;
103 }
104>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
105​
106const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000;
107const DPOP_AUTHORIZATION_TIMEOUT_MS = 30_000;
108const CHECKED_DESCRIPTOR_TTL_NANOS = 5_000_000_000n;
109// A loopback or LAN host answers well inside this, so a dead one frees its attempt sooner.
110const NEARBY_DESCRIPTOR_TIMEOUT_MS = 3_000;
111​
112/** A token with the descriptor its mint read. A cached token has none. */
113interface IssuedDpopToken {
114 readonly token: TokenStore.RemoteDpopAccessToken;
115 readonly descriptor?: ExecutionEnvironmentDescriptor;
117​
118function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) {
119 return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError"
120 ? error
121 : mapRemoteDpopEnvironmentError(error);
123​
124const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* (
125 httpBaseUrl: string,
126 connectionMethod: ClientConnectionMethod,
127 timeoutMs?: number,
128) {
129 return yield* fetchRemoteEnvironmentDescriptor({
130 httpBaseUrl,
131 ...(timeoutMs === undefined ? {} : { timeoutMs }),
132 }).pipe(Effect.mapError((error) => mapRemoteEnvironmentError(error, connectionMethod)));
133});
134​
135export const make = Effect.gen(function* () {
136 const serviceScope = yield* Scope.Scope;
137 const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
138 const relay = yield* ManagedRelay.ManagedRelayClient;
139 const cloudSession = yield* ClientCapabilities.CloudSession;
140 const deviceIdentity = yield* ClientCapabilities.RelayDeviceIdentity;
141 const presentation = yield* ClientCapabilities.ClientPresentation;
142 const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore;
143 const httpClient = yield* HttpClient.HttpClient;
144 const tokenLock = yield* Semaphore.make(1);
145 const tokenOwners = new Map<
146 EnvironmentId,
147 { readonly accessToken: string; readonly identity: ClientCapabilities.CloudSessionIdentity }
148 >();
149 const pendingTokens = new Map<
150 EnvironmentId,
151 {
152 readonly id: object;
153 readonly identity: ClientCapabilities.CloudSessionIdentity;
154 readonly thumbprint: string;
155 readonly issued: Effect.Effect<IssuedDpopToken, ConnectionAttemptError>;
156 }
157 >();
158​
159 // A route or host check hands its descriptor to the connect it leads to, once,
160 // so the connect skips a round trip. Only over HTTPS or loopback: any other
161 // plain http address can belong to another machine after a network change.
162 const checkedDescriptors = new Map<
163 string,
164 { readonly descriptor: ExecutionEnvironmentDescriptor; readonly at: bigint }
165 >();
166​
167 /**
168 * A saved LAN address can belong to another machine on a different network.
169 */
170 const verifyDirectEndpoint = Effect.fn("clientRuntime.connection.remote.verifyDirectEndpoint")(
171 function* (input: {
172 readonly expectedEnvironmentId: EnvironmentId;
173 readonly httpBaseUrl: string;
174 readonly connectionMethod: ClientConnectionMethod;
175 }) {
176 const checked = checkedDescriptors.get(input.httpBaseUrl);
177 checkedDescriptors.delete(input.httpBaseUrl);
178 // A remembered mismatch still reads again, so a host that answers for the
179 // right environment a moment later is not blocked.
180 const descriptor =
181 checked !== undefined &&
182 checked.descriptor.environmentId === input.expectedEnvironmentId &&
183 (yield* Clock.monotonicTimeNanos) - checked.at <= CHECKED_DESCRIPTOR_TTL_NANOS
184 ? checked.descriptor
185 : yield* fetchDescriptor(
186 input.httpBaseUrl,
187 input.connectionMethod,
188 // An SSH forward listens on loopback but reaches a remote host.
189 input.connectionMethod === "direct" && isNearbyAddress(input.httpBaseUrl)
190 ? NEARBY_DESCRIPTOR_TIMEOUT_MS
191 : undefined,
192 ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
193 if (descriptor.environmentId !== input.expectedEnvironmentId) {
194 return yield* environmentMismatchError({
195 expected: input.expectedEnvironmentId,
196 actual: descriptor.environmentId,
197 });
198 }
199 return descriptor;
200 },
201 );
202​
203 const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")(
204 function* (input: {
205 readonly expectedEnvironmentId: Parameters<
206 RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]
207 >[0]["expectedEnvironmentId"];
208 readonly httpBaseUrl: string;
209 readonly wsBaseUrl: string;
210 readonly bearerToken: string;
211 readonly connectionMethod: ClientConnectionMethod;
212 }) {
213 const descriptor = yield* verifyDirectEndpoint(input);
214 const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({
215 wsBaseUrl: input.wsBaseUrl,
216 httpBaseUrl: input.httpBaseUrl,
217 bearerToken: input.bearerToken,
218 clientMetadata: presentation.metadata,
219 connectionMethod: input.connectionMethod,
220 }).pipe(
221 Effect.mapError(mapRemoteEnvironmentError),
222 Effect.provideService(HttpClient.HttpClient, httpClient),
223 );
224 return {
225 environmentId: descriptor.environmentId,
226 label: descriptor.label,
227 httpBaseUrl: input.httpBaseUrl,
228 socketUrl,
229 descriptor,
230 httpAuthorization: {
231 _tag: "Bearer" as const,
232 token: input.bearerToken,
233 },
234 };
235 },
236 );
237​
238 const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")(
239 function* (
240 token: TokenStore.RemoteDpopAccessToken,
241 timeoutMs?: number,
242 endpoint: { readonly httpBaseUrl: string; readonly wsBaseUrl: string } = token.endpoint,
243 ) {
244 return yield* resolveRemoteDpopWebSocketConnectionUrl({
245 wsBaseUrl: endpoint.wsBaseUrl,
246 httpBaseUrl: endpoint.httpBaseUrl,
247 accessToken: token.accessToken,
248 createProof: (proof) =>
249 signer.createProof(proof).pipe(
250 Effect.mapError(
251 () =>
252 new ConnectionBlockedError({
253 reason: "configuration",
254 detail: "Could not create the websocket authorization proof.",
255 }),
256 ),
257 ),
258 clientMetadata: presentation.metadata,
259 connectionMethod: "relay",
260 ...(timeoutMs === undefined ? {} : { timeoutMs }),
261 });
262 },
263 Effect.provideService(HttpClient.HttpClient, httpClient),
264 );
265​
266 const sessionChanged = () =>
267 new ConnectionBlockedError({
268 reason: "authentication",
269 detail: "Your cloud sign-in changed. Sign in again to authorize the environment.",
270 });
271​
272 // A missing identity before the attempt captures one means the cloud session
273 // is still loading, not a sign-out. Only a captured identity that goes away
274 // or changes is evidence of one.
275 const sessionPending = () =>
276 new ConnectionTransientError({
277 reason: "session-pending",
278 detail: "Waiting for the T3 Connect sign-in to load.",
279 });
280​
281 const assertSession = Effect.fnUntraced(function* (
282 identity: ClientCapabilities.CloudSessionIdentity,
283 ) {
284 const current = yield* cloudSession.identity;
285 if (Option.isNone(current) || current.value !== identity) {
286 return yield* sessionChanged();
287 }
288 });
289​
290 // Called under tokenLock so a late rejection cannot remove a newer credential.
291 const removeRejectedToken = Effect.fnUntraced(function* (
292 environmentId: EnvironmentId,
293 accessToken: string,
294 ) {
295 const cached = yield* tokenStore.get(environmentId);
296 if (Option.isSome(cached) && cached.value.accessToken === accessToken) {
297 yield* tokenStore.remove(environmentId);
298 tokenOwners.delete(environmentId);
299 }
300 });
301​
302 const obtainBootstrap = Effect.fn("relay.connection.bootstrap.obtain")(function* (
303 environmentId: EnvironmentId,
304 identity: ClientCapabilities.CloudSessionIdentity,
305 ) {
306 yield* assertSession(identity);
307 const clerkToken = yield* cloudSession.clerkToken.pipe(
308 Effect.withSpan("relay.connection.cloudSessionToken.resolve"),
309 );
310 const deviceId = yield* deviceIdentity.deviceId.pipe(
311 Effect.withSpan("relay.connection.deviceIdentity.resolve"),
312 );
313 yield* assertSession(identity);
314 const connected = yield* relay
315 .connectEnvironment({
316 clerkToken,
317 scopes: [RelayEnvironmentConnectScope],
318 environmentId,
319 ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}),
320 })
321 .pipe(Effect.mapError(mapManagedRelayError));
322 if (connected.environmentId !== environmentId) {
323 return yield* environmentMismatchError({
324 expected: environmentId,
325 actual: connected.environmentId,
326 });
327 }
328 yield* assertSession(identity);
329 return connected;
330 });
331​
332 const exchangeDpopToken = Effect.fn("clientRuntime.connection.remote.exchangeDpopToken")(
333 function* (
334 environmentId: EnvironmentId,
335 thumbprint: string,
336 identity: ClientCapabilities.CloudSessionIdentity,
337 ) {
338 const bootstrap = yield* obtainBootstrap(environmentId, identity);
339 const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl, "relay").pipe(
340 Effect.provideService(HttpClient.HttpClient, httpClient),
341 Effect.withSpan("environment.authorization.descriptor"),
342 );
343 if (descriptor.environmentId !== environmentId) {
344 return yield* environmentMismatchError({
345 expected: environmentId,
346 actual: descriptor.environmentId,
347 });
348 }
349 const bootstrapProof = yield* signer
350 .createProof({
351 method: "POST",
352 url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"),
353 })
354 .pipe(
355 Effect.mapError(
356 () =>
357 new ConnectionBlockedError({
358 reason: "configuration",
359 detail: "Could not create the environment authorization proof.",
360 }),
361 ),
362 );
363 yield* assertSession(identity);
364 const access = yield* exchangeRemoteDpopAccessToken({
365 httpBaseUrl: bootstrap.endpoint.httpBaseUrl,
366 credential: bootstrap.credential,
367 dpopProof: bootstrapProof,
368 clientMetadata: presentation.metadata,
369 }).pipe(
370 Effect.mapError(mapRemoteDpopEnvironmentError),
371 Effect.provideService(HttpClient.HttpClient, httpClient),
372 Effect.withSpan("environment.authorization.accessToken.exchange"),
373 );
374 const issuedAt = yield* Clock.currentTimeMillis;
375 const token = new TokenStore.RemoteDpopAccessToken({
376 environmentId: descriptor.environmentId,
377 accountId: identity.accountId,
378 label: descriptor.label,
379 endpoint: bootstrap.endpoint,
380 accessToken: access.access_token,
381 expiresAtEpochMs: issuedAt + access.expires_in * 1_000,
382 dpopThumbprint: thumbprint,
383 });
384 return { token, descriptor } satisfies IssuedDpopToken;
385 },
386 );
387​
388 const getDpopToken = Effect.fn("clientRuntime.connection.remote.getDpopToken")(function* (
389 input: Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpopHttp"]>[0],
390 ) {
391 const session = yield* cloudSession.identity;
392 if (Option.isNone(session)) {
393 return yield* sessionPending();
394 }
395 const identity = session.value;
396 const thumbprint = yield* signer.thumbprint.pipe(
397 Effect.mapError(
398 () =>
399 new ConnectionBlockedError({
400 reason: "configuration",
401 detail: "Could not load the environment authorization key.",
402 }),
403 ),
404 Effect.withSpan("environment.authorization.dpopKey.resolve"),
405 );
406 const selected = yield* tokenLock.withPermits(1)(
407 Effect.gen(function* () {
408 yield* assertSession(identity);
409 const now = yield* Clock.currentTimeMillis;
410 const cached = yield* tokenStore
411 .get(input.expectedEnvironmentId)
412 .pipe(Effect.withSpan("environment.authorization.accessToken.cache"));
413 const owner = tokenOwners.get(input.expectedEnvironmentId);
414 if (
415 Option.isSome(cached) &&
416 cached.value.environmentId === input.expectedEnvironmentId &&
417 cached.value.accountId === identity.accountId &&
418 cached.value.dpopThumbprint === thumbprint &&
419 cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS &&
420 cached.value.accessToken !== input.rejectedAccessToken &&
421 (owner?.accessToken !== cached.value.accessToken || owner.identity === identity)
422 ) {
423 tokenOwners.set(input.expectedEnvironmentId, {
424 accessToken: cached.value.accessToken,
425 identity,
426 });
427 return {
428 issued: Effect.succeed<IssuedDpopToken>({ token: cached.value }),
429 fromCache: true,
430 };
431 }
432 if (input.rejectedAccessToken !== undefined) {
433 yield* removeRejectedToken(input.expectedEnvironmentId, input.rejectedAccessToken);
434 }
435 const pending = pendingTokens.get(input.expectedEnvironmentId);
436 if (pending?.identity === identity && pending.thumbprint === thumbprint) {
437 return { issued: pending.issued, fromCache: false };
438 }
439​
440 const id = {};
441 const fiber = yield* exchangeDpopToken(
442 input.expectedEnvironmentId,
443 thumbprint,
444 identity,
445 ).pipe(
446 Effect.flatMap((issued) =>
447 tokenLock.withPermits(1)(
448 Effect.gen(function* () {
449 const { token } = issued;
450 yield* assertSession(identity);
451 yield* tokenStore
452 .put(token)
453 .pipe(Effect.withSpan("environment.authorization.accessToken.persist"));
454 const current = yield* cloudSession.identity;
455 if (Option.isNone(current) || current.value !== identity) {
456 yield* removeRejectedToken(input.expectedEnvironmentId, token.accessToken);
457 return yield* sessionChanged();
458 }
459 tokenOwners.set(input.expectedEnvironmentId, {
460 accessToken: token.accessToken,
461 identity,
462 });
463 if (pendingTokens.get(input.expectedEnvironmentId)?.id === id) {
464 pendingTokens.delete(input.expectedEnvironmentId);
465 }
466 return issued;
467 }),
468 ),
469 ),
470 Effect.timeoutOrElse({
471 duration: DPOP_AUTHORIZATION_TIMEOUT_MS,
472 orElse: () =>
473 Effect.fail(
474 new ConnectionTransientError({
475 reason: "timeout",
476 detail: "Timed out renewing the environment credential.",
477 }),
478 ),
479 }),
480 Effect.ensuring(
481 tokenLock.withPermits(1)(
482 Effect.sync(() => {
483 if (pendingTokens.get(input.expectedEnvironmentId)?.id === id) {
484 pendingTokens.delete(input.expectedEnvironmentId);
485 }
486 }),
487 ),
488 ),
489 // One caller's timeout must not cancel renewal for other HTTP requests.
490 Effect.forkIn(serviceScope),
491 );
492 const issued = Fiber.join(fiber);
493 pendingTokens.set(input.expectedEnvironmentId, { id, identity, thumbprint, issued });
494 return { issued, fromCache: false };
495 }),
496 );
497 yield* Effect.annotateCurrentSpan({
498 "connection.remote_token_cache": selected.fromCache ? "hit" : "miss",
499 });
500 const issued = yield* selected.issued;
501 yield* assertSession(identity);
502 return { ...issued, fromCache: selected.fromCache, identity };
503 });
504​
505 const httpAuthorization = (
506 token: TokenStore.RemoteDpopAccessToken,
507 httpBaseUrl = token.endpoint.httpBaseUrl,
508 ): AuthorizedRemoteHttpEnvironment => ({
509 environmentId: token.environmentId,
510 label: token.label,
511 httpBaseUrl,
512 httpAuthorization: {
513 _tag: "Dpop",
514 accessToken: token.accessToken,
515 expiresAtEpochMs: token.expiresAtEpochMs,
516 },
517 });
518​
519 const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")(function* (
520 input: Parameters<RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]>[0],
521 ) {
522 const endpoint = input.directEndpoint;
523 const directDescriptor =
524 endpoint === undefined
525 ? undefined
526 : yield* verifyDirectEndpoint({
527 expectedEnvironmentId: input.expectedEnvironmentId,
528 httpBaseUrl: endpoint.httpBaseUrl,
529 connectionMethod: "direct",
530 });
531 const authorized = (
532 token: TokenStore.RemoteDpopAccessToken,
533 socketUrl: string,
534 descriptor: ExecutionEnvironmentDescriptor,
535 ) => ({ ...httpAuthorization(token, endpoint?.httpBaseUrl), socketUrl, descriptor });
536 // A cached token comes without a descriptor. That read needs no ticket, so
537 // it runs beside the ticket request, and a failed ticket abandons it.
538 const openSocket = Effect.fnUntraced(function* (issued: IssuedDpopToken, timeoutMs?: number) {
539 const socket = createDpopSocketUrl(issued.token, timeoutMs, endpoint).pipe(Effect.result);
540 const known = directDescriptor ?? issued.descriptor;
541 if (known !== undefined) {
542 return Result.map(yield* socket, (socketUrl) => authorized(issued.token, socketUrl, known));
543 }
544 const reading = yield* fetchDescriptor(issued.token.endpoint.httpBaseUrl, "relay").pipe(
545 Effect.provideService(HttpClient.HttpClient, httpClient),
546 Effect.forkChild,
547 );
548 const opened = yield* socket;
549 if (Result.isFailure(opened)) {
550 yield* Fiber.interrupt(reading);
551 return Result.fail(opened.failure);
552 }
553 return Result.succeed(authorized(issued.token, opened.success, yield* Fiber.join(reading)));
554 });
555 let selected = yield* getDpopToken(input);
556 if (selected.fromCache) {
557 const cachedSocket = yield* openSocket(selected, CACHED_ENDPOINT_SOCKET_TIMEOUT_MS);
558 if (Result.isSuccess(cachedSocket)) {
559 yield* assertSession(selected.identity);
560 return cachedSocket.success;
561 }
562 if (cachedSocket.failure._tag === "ConnectionBlockedError") {
563 return yield* mapDpopSocketError(cachedSocket.failure);
564 }
565 // An unreachable direct address says nothing about the token, so keep
566 // it for the T3 Connect route rather than forcing a relay round trip.
567 if (endpoint !== undefined && cachedSocket.failure._tag !== "EnvironmentAuthInvalidError") {
568 return yield* mapDpopSocketError(cachedSocket.failure);
569 }
570 yield* assertSession(selected.identity);
571 selected = yield* getDpopToken({
572 ...input,
573 rejectedAccessToken: selected.token.accessToken,
574 });
575 }
576 const socket = yield* openSocket(selected);
577 if (Result.isFailure(socket)) {
578 if (endpoint === undefined || socket.failure._tag === "ConnectionBlockedError") {
579 yield* tokenLock.withPermits(1)(
580 removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken),
581 );
582 }
583 return yield* mapDpopSocketError(socket.failure);
584 }
585 yield* assertSession(selected.identity);
586 return socket.success;
587 });
588​
589 return RemoteEnvironmentAuthorization.of({
590 authorizeBearer,
591 // Untraced: it runs on every route and host check.
592 rememberCheckedDescriptor: Effect.fnUntraced(function* (httpBaseUrl, descriptor, keepsItsHost) {
593 if (Option.isNone(descriptor) || !keepsItsHost) {
594 checkedDescriptors.delete(httpBaseUrl);
595 return;
596 }
597 checkedDescriptors.set(httpBaseUrl, {
598 descriptor: descriptor.value,
599 at: yield* Clock.monotonicTimeNanos,
600 });
601 }),
602 authorizeDpop: Effect.fn("environment.authorization")(function (input) {
603 return authorizeDpop(input);
604 }),
605 // The relay tracer must wrap span creation, not just the token request.
606 authorizeDpopHttp: Effect.fnUntraced(function* (input) {
607 return yield* getDpopToken(input).pipe(
608 Effect.map(({ token }) => httpAuthorization(token)),
609 Effect.withSpan("environment.authorization.http"),
610 withRelayClientTracing,
611 );
612 }),
613 // Untraced: a span here would stay open for the socket's lifetime. Each renewal has its own.
614 renewDpopBeforeExpiry: Effect.fnUntraced(function* (input) {
615 let expiresAtEpochMs = input.expiresAtEpochMs;
616 let now = yield* Clock.currentTimeMillis;
617 // Past expiry the server has closed the socket.
618 while (now < expiresAtEpochMs) {
619 // getDpopToken reuses a cached token until it is within the skew of expiry.
620 // The floor spaces out retries and keeps a wall-clock jump from spinning.
621 yield* Effect.sleep(
622 Math.max(5_000, expiresAtEpochMs - DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - now),
623 );
624 const renewed = yield* getDpopToken({
625 expectedEnvironmentId: input.expectedEnvironmentId,
626 }).pipe(
627 Effect.map(({ token }) => token.expiresAtEpochMs),
628 Effect.withSpan("environment.authorization.renewSocket"),
629 withRelayClientTracing,
630 // A relay blip retries until expiry; a blocked credential ends the loop.
631 Effect.catchTags({ ConnectionTransientError: () => Effect.succeed(expiresAtEpochMs) }),
632 );
633 expiresAtEpochMs = Math.max(expiresAtEpochMs, renewed);
634 now = yield* Clock.currentTimeMillis;
635 }
636 }),
637 });
638});
639​
640export const layer = Layer.effect(RemoteEnvironmentAuthorization, make);