Skip to content
90 changes: 90 additions & 0 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { assert, describe, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
import {
Expand Down Expand Up @@ -144,4 +147,91 @@ describe("DesktopLocalEnvironmentAuth", () => {
);
}).pipe(Effect.provide(TestClock.layer())),
);

const tokenResponse = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientResponse.fromWeb(
request,
new Response(
JSON.stringify({
access_token: "desktop-bearer-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "Bearer",
expires_in: 3600,
scope: "orchestration:read",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const layerPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([
{
id: PRIMARY_LOCAL_ENVIRONMENT_ID,
label: Effect.succeed("Windows"),
currentConfig: Effect.succeedSome(config),
},
]),
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);
// Answers the first `failures` exchanges with `failure`, then with a token.
const makeExchange = (failures: number, failure: () => Response) =>
Effect.gen(function* () {
const requestCount = yield* Ref.make(0);
const layerHttpClient = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Ref.updateAndGet(requestCount, (count) => count + 1).pipe(
Effect.map((count) =>
count <= failures
? HttpClientResponse.fromWeb(request, failure())
: tokenResponse(request),
),
),
),
);
const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe(
Effect.provide(
DesktopLocalEnvironmentAuth.layer.pipe(
Layer.provide(Layer.mergeAll(layerPool, layerHttpClient)),
),
),
);
return { auth, requestCount };
});

it.effect("retries a backend that is still starting", () =>
Effect.gen(function* () {
const { auth, requestCount } = yield* makeExchange(
2,
() => new Response("", { status: 503 }),
);

const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild);
yield* TestClock.adjust(Duration.seconds(1));

assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token");
assert.strictEqual(yield* Ref.get(requestCount), 3);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("does not retry a rejected bootstrap credential", () =>
Effect.gen(function* () {
const { auth, requestCount } = yield* makeExchange(1, () =>
Response.json(
{
_tag: "EnvironmentAuthInvalidError",
code: "auth_invalid",
reason: "invalid_credential",
traceId: "trace-1",
},
{ status: 401 },
),
);

const fiber = yield* auth.getBearerToken.pipe(Effect.flip, Effect.forkChild);
yield* TestClock.adjust(Duration.seconds(1));
const error = yield* Fiber.join(fiber);

assert.strictEqual(error._tag, "DesktopLocalEnvironmentAuthSessionBootstrapError");
assert.strictEqual(yield* Ref.get(requestCount), 1);
}).pipe(Effect.provide(TestClock.layer())),
);
});
29 changes: 29 additions & 0 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization";
import type { RemoteEnvironmentRequestError } from "@t3tools/client-runtime/rpc";
import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
import { currentDesktopBootstrapToken } from "@t3tools/shared/desktopBootstrapToken";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
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 Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as HttpClient from "effect/http/HttpClient";

import * as DesktopBackendPool from "./DesktopBackendPool.ts";

// A loopback /oauth/token exchange can fail transiently while the backend
// settles (a 502-504, a refused or reset connection, a timeout). Retry those
// for as long as the renderer's own bootstrap does; a rejected credential or a
// server error is final.
const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15);
const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500);
const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]);

const isTransientBearerBootstrapError = (error: RemoteEnvironmentRequestError): boolean => {
switch (error._tag) {
case "RemoteEnvironmentAuthFetchError":
case "RemoteEnvironmentAuthTimeoutError":
return true;
case "RemoteEnvironmentAuthUndeclaredStatusError":
return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status);
default:
return false;
}
};

export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema.TaggedError<DesktopLocalEnvironmentAuthBackendNotConfiguredError>()(
"DesktopLocalEnvironmentAuthBackendNotConfiguredError",
{},
Expand Down Expand Up @@ -86,6 +109,12 @@ export const make = Effect.gen(function* () {
},
}).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.retry({
while: isTransientBearerBootstrapError,
schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL),
}),
// Bounds the attempts and any request still in flight at the deadline.
Effect.timeout(BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT),
Effect.mapError(
(cause) =>
new DesktopLocalEnvironmentAuthSessionBootstrapError({
Expand Down
Loading