Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 60 additions & 41 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const config = {
captureOutput: true,
};

const tokenAt = (nowMs: number) =>
currentDesktopBootstrapToken("desktop-secret", nowMs).pipe(Effect.provide(NodeCrypto.layerMac));

describe("DesktopLocalEnvironmentAuth", () => {
it.effect("exchanges the desktop bootstrap credential only once", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -103,35 +106,27 @@ describe("DesktopLocalEnvironmentAuth", () => {
);

it.effect(
"exchanges the current window's token when the backend was launched with a secret",
"shares cached and renewed bearers, deriving the current bootstrap token after suspend",
() =>
Effect.gen(function* () {
const presented = yield* Ref.make<string | null>(null);
const httpClientLayer = Layer.succeed(
const presented = yield* Ref.make<Array<string | null>>([]);
const layerHttpClient = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => {
const body =
request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
return Ref.set(presented, new URLSearchParams(body).get("subject_token")).pipe(
Effect.as(
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" } },
),
),
),
);
}),
HttpClient.make((request) =>
Effect.gen(function* () {
const body =
request.body._tag === "Uint8Array"
? new TextDecoder().decode(request.body.body)
: "";
const credentials = yield* Ref.updateAndGet(presented, (tokens) => [
...tokens,
new URLSearchParams(body).get("subject_token"),
]);
return tokenResponse(request, `desktop-bearer-token-${credentials.length}`);
}),
),
);
const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
const layerSecretPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([
{
id: PRIMARY_LOCAL_ENVIRONMENT_ID,
Expand All @@ -144,35 +139,59 @@ describe("DesktopLocalEnvironmentAuth", () => {
},
]),
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);

yield* TestClock.setTime(DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1);
yield* Effect.gen(function* () {
const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth;
return yield* auth.getBearerToken;
}).pipe(
const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe(
Effect.provide(
DesktopLocalEnvironmentAuth.layer.pipe(
Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer, NodeCrypto.layerMac)),
Layer.provide(Layer.mergeAll(layerSecretPool, layerHttpClient, NodeCrypto.layerMac)),
),
),
);
const concurrentReads = Effect.all([auth.getBearerToken, auth.getBearerToken], {
concurrency: "unbounded",
});
const start = DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1;
yield* TestClock.setTime(start);

assert.strictEqual(
yield* Ref.get(presented),
yield* currentDesktopBootstrapToken(
"desktop-secret",
DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1,
).pipe(Effect.provide(NodeCrypto.layerMac)),
);
assert.deepStrictEqual(yield* concurrentReads, [
"desktop-bearer-token-1",
"desktop-bearer-token-1",
]);
yield* TestClock.adjust(Duration.seconds(3_539));
assert.strictEqual(yield* auth.getBearerToken, "desktop-bearer-token-1");
assert.deepStrictEqual(yield* Ref.get(presented), [yield* tokenAt(start)]);

yield* TestClock.adjust(Duration.seconds(2));
assert.deepStrictEqual(yield* concurrentReads, [
"desktop-bearer-token-2",
"desktop-bearer-token-2",
]);
assert.deepStrictEqual(yield* Ref.get(presented), [
yield* tokenAt(start),
yield* tokenAt(start + 3_541_000),
]);

yield* TestClock.adjust(Duration.days(31));
assert.deepStrictEqual(yield* concurrentReads, [
"desktop-bearer-token-3",
"desktop-bearer-token-3",
]);
assert.deepStrictEqual(yield* Ref.get(presented), [
yield* tokenAt(start),
yield* tokenAt(start + 3_541_000),
yield* tokenAt(start + 3_541_000 + Duration.toMillis(Duration.days(31))),
]);
}).pipe(Effect.provide(TestClock.layer())),
);

const tokenResponse = (request: HttpClientRequest.HttpClientRequest) =>
const tokenResponse = (
request: HttpClientRequest.HttpClientRequest,
accessToken = "desktop-bearer-token",
) =>
HttpClientResponse.fromWeb(
request,
new Response(
JSON.stringify({
access_token: "desktop-bearer-token",
access_token: accessToken,
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "Bearer",
expires_in: 3600,
Expand Down
20 changes: 16 additions & 4 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,20 @@ export const make = Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const httpClient = yield* HttpClient.HttpClient;
const mac = yield* Mac.Mac;
const tokenRef = yield* Ref.make(Option.none<string>());
const tokenRef = yield* Ref.make(
Option.none<{ readonly accessToken: string; readonly expiresAtMillis: number }>(),
);
const mutex = yield* Semaphore.make(1);

const getBearerToken = mutex
.withPermits(1)(
Effect.gen(function* () {
const cached = yield* Ref.get(tokenRef);
if (Option.isSome(cached)) {
return cached.value;
if (
Option.isSome(cached) &&
cached.value.expiresAtMillis > (yield* Clock.currentTimeMillis) + 60_000
) {
return cached.value.accessToken;
}

const instances = yield* pool.list;
Expand All @@ -101,6 +106,7 @@ export const make = Effect.gen(function* () {
if (!credential) {
return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
}
const requestedAt = yield* Clock.currentTimeMillis;
const session = yield* bootstrapRemoteBearerSession({
httpBaseUrl: config.httpBaseUrl.href,
credential,
Expand All @@ -122,7 +128,13 @@ export const make = Effect.gen(function* () {
}),
),
);
yield* Ref.set(tokenRef, Option.some(session.access_token));
yield* Ref.set(
tokenRef,
Option.some({
accessToken: session.access_token,
expiresAtMillis: requestedAt + session.expires_in * 1_000,
}),
);
return session.access_token;
}),
)
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
import { expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Duration from "effect/Duration";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";
import * as Redacted from "effect/Redacted";

import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -532,6 +534,47 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))(
),
);

it.effect("renews a legacy desktop bearer after its 30-day session expires", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const first = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
"desktop-bootstrap-token",
undefined,
requestMetadata,
);
const firstSession = yield* serverAuth.authenticateHttpRequest(
makeBearerRequest(first.access_token),
);
expect(first.expires_in).toBe(Duration.toSeconds(Duration.days(30)));

yield* TestClock.adjust(Duration.days(31));
const expired = yield* Effect.flip(
serverAuth.authenticateHttpRequest(makeBearerRequest(first.access_token)),
);
expect(expired._tag).toBe("ServerAuthInvalidCredentialError");
expect(expired.cause).toMatchObject({ _tag: "SessionTokenExpiredError" });

const renewed = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
"desktop-bootstrap-token",
undefined,
requestMetadata,
);
const renewedSession = yield* serverAuth.authenticateHttpRequest(
makeBearerRequest(renewed.access_token),
);
expect(renewedSession.sessionId).not.toBe(firstSession.sessionId);
expect(renewedSession.subject).toBe("desktop-bootstrap");
expect(renewedSession.scopes).toEqual(firstSession.scopes);
}).pipe(
Effect.provide(
layerEnvironmentAuth({
mode: "desktop",
desktopBootstrapToken: "desktop-bootstrap-token",
}).pipe(Layer.provideMerge(TestClock.layer())),
),
),
);

it.effect("rotates desktop bearer sessions without accumulating authorized clients", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
Expand Down
14 changes: 9 additions & 5 deletions apps/server/src/auth/PairingGrantStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PairingGrantStor
const exact = yield* bootstrapCredentials.consume("abcdefghjkmn");
const otherCase = yield* Effect.flip(bootstrapCredentials.consume("ABCDEFGHJKMN"));

const forbidden = yield* Effect.flip(
bootstrapCredentials.consume("abcdefghjkmn", { requiredScopes: ["review:write"] }),
);
expect(forbidden._tag).toBe("BootstrapCredentialScopeNotGrantedError");
expect(exact.method).toBe("desktop-bootstrap");
expect(otherCase._tag).toBe("UnknownBootstrapCredentialError");
}).pipe(
Expand All @@ -149,7 +153,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PairingGrantStor
),
);

it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
it.effect("accepts the configured legacy desktop credential after a long suspend", () =>
Effect.gen(function* () {
const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;

Expand All @@ -158,10 +162,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PairingGrantStor
expect(stillValid.method).toBe("desktop-bootstrap");

yield* TestClock.adjust(Duration.hours(13));
const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));

expect(expired._tag).toBe("ExpiredBootstrapCredentialError");
expect(expired.message).toContain("Bootstrap credential expired");
const renewed = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
expect(renewed.method).toBe("desktop-bootstrap");
const wrong = yield* Effect.flip(bootstrapCredentials.consume("wrong-desktop-token"));
expect(wrong._tag).toBe("UnknownBootstrapCredentialError");
}).pipe(
Effect.provide(
Layer.merge(
Expand Down
Loading
Loading