diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e82e3afea7303..3d2702a209f16 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -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* () { @@ -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(null); - const httpClientLayer = Layer.succeed( + const presented = yield* Ref.make>([]); + 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, @@ -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, diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 6c7d24623d40f..3a6a2a9539b0c 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -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()); + 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; @@ -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, @@ -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; }), ) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 365203e03db2c..da92a30a1d6ba 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -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"; @@ -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; diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5184beb096807..aaf34f8753f56 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -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( @@ -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; @@ -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( diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index f5f558d57526a..720374f79e112 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Digest is effectful; desktop credential checks hash synchronously. +import * as NodeCrypto from "node:crypto"; import { AuthStandardClientScopes, type AuthClientMetadata, @@ -25,7 +27,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -308,21 +309,6 @@ export class PairingGrantStore extends Context.Service< } >()("t3/auth/PairingGrantStore") {} -interface StoredBootstrapGrant extends BootstrapGrant { - readonly remainingUses: number | "unbounded"; -} - -type ConsumeResult = - | { - readonly _tag: "error"; - readonly reason: "not-found" | "expired" | "scope-not-granted"; - readonly error: BootstrapCredentialError; - } - | { - readonly _tag: "success"; - readonly grant: BootstrapGrant; - }; - const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); const toPairingLink = (row: AuthPairingLinks.AuthPairingLinkRecord): AuthPairingLink => ({ @@ -341,7 +327,6 @@ const toPairingLink = (row: AuthPairingLinks.AuthPairingLinkRecord): AuthPairing } : {}), }); -const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); const DEV_STARTUP_TTL_HOURS = Duration.hours(24); const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_CODE_ALPHABET.length) * PAIRING_CODE_ALPHABET.length; @@ -356,7 +341,9 @@ const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const connectDevices = yield* AuthConnectDevices.AuthConnectDeviceRepository; - const seededGrantsRef = yield* Ref.make(new Map()); + const desktopTokenHash = config.desktopBootstrapToken + ? NodeCrypto.createHash("sha256").update(config.desktopBootstrapToken).digest() + : undefined; const changesPubSub = yield* PubSub.unbounded(); const generatePairingToken = Effect.gen(function* () { let credential = ""; @@ -382,13 +369,6 @@ const make = Effect.gen(function* () { return credential; }); - const seedGrant = (credential: string, grant: StoredBootstrapGrant) => - Ref.update(seededGrantsRef, (current) => { - const next = new Map(current); - next.set(credential, grant); - return next; - }); - const emitUpsert = (pairingLink: AuthPairingLink) => PubSub.publish(changesPubSub, { type: "pairingLinkUpserted", @@ -401,29 +381,7 @@ const make = Effect.gen(function* () { id, }).pipe(Effect.asVoid); - // Accept tokens derived from the rotating desktop secret; older desktops send only a token. - const desktopBootstrapSecret = config.desktopBootstrapSecret; const mac = yield* Mac.Mac; - const consumeRotatingDesktopToken = (credential: string, nowMs: number) => - desktopBootstrapSecret === undefined - ? Effect.succeed(false) - : isValidDesktopBootstrapToken(desktopBootstrapSecret, credential, nowMs).pipe( - Effect.provideService(Mac.Mac, mac), - ); - - if (config.desktopBootstrapToken && desktopBootstrapSecret === undefined) { - const now = yield* DateTime.now; - yield* seedGrant(config.desktopBootstrapToken, { - method: "desktop-bootstrap", - scopes: AuthStandardClientScopes, - subject: "desktop-bootstrap", - expiresAt: DateTime.add(now, { - milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS), - }), - // Reloads and expired bearer sessions must be able to re-exchange the desktop seed. - remainingUses: "unbounded", - }); - } const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", @@ -533,7 +491,31 @@ const make = Effect.gen(function* () { const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( function* (credential, input) { const now = yield* DateTime.now; - if (yield* consumeRotatingDesktopToken(credential, now.epochMilliseconds)) { + // The configured desktop credential must remain exchangeable after a long suspend. + const secret = config.desktopBootstrapSecret; + const isDesktopCredential = + secret !== undefined + ? yield* isValidDesktopBootstrapToken(secret, credential, now.epochMilliseconds).pipe( + Effect.provideService(Mac.Mac, mac), + ) + : desktopTokenHash !== undefined && + Mac.timingSafeEqual( + desktopTokenHash, + NodeCrypto.createHash("sha256").update(credential).digest(), + ); + if (isDesktopCredential) { + if ( + (input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => + AuthStandardClientScopes.some((granted) => granted === scope), + )) || + (input?.requiredScopes !== undefined && + !input.requiredScopes.every((scope) => + AuthStandardClientScopes.some((granted) => granted === scope), + )) + ) { + return yield* new BootstrapCredentialScopeNotGrantedError({}); + } return { method: "desktop-bootstrap", scopes: AuthStandardClientScopes, @@ -543,99 +525,6 @@ const make = Effect.gen(function* () { }), } satisfies BootstrapGrant; } - const seededResult: ConsumeResult = yield* Ref.modify( - seededGrantsRef, - (current): readonly [ConsumeResult, Map] => { - const grant = current.get(credential); - if (!grant) { - return [ - { - _tag: "error", - reason: "not-found", - error: new UnknownBootstrapCredentialError({}), - }, - current, - ]; - } - - const next = new Map(current); - if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { - next.delete(credential); - return [ - { - _tag: "error", - reason: "expired", - error: new ExpiredBootstrapCredentialError({}), - }, - next, - ]; - } - - if (grant.proofKeyThumbprint && grant.proofKeyThumbprint !== input?.proofKeyThumbprint) { - return [ - { - _tag: "error", - reason: "not-found", - error: new BootstrapCredentialProofKeyMismatchError({}), - }, - next, - ]; - } - - if ( - (input?.requestedScopes !== undefined && - !input.requestedScopes.some((scope) => grant.scopes.includes(scope))) || - (input?.requiredScopes !== undefined && - !input.requiredScopes.every((scope) => grant.scopes.includes(scope))) - ) { - return [ - { - _tag: "error", - reason: "scope-not-granted", - error: new BootstrapCredentialScopeNotGrantedError({}), - }, - current, - ]; - } - - const remainingUses = grant.remainingUses; - if (typeof remainingUses === "number") { - if (remainingUses <= 1) { - next.delete(credential); - } else { - next.set(credential, { - ...grant, - remainingUses: remainingUses - 1, - }); - } - } - - return [ - { - _tag: "success", - grant: { - method: grant.method, - scopes: grant.scopes, - subject: grant.subject, - ...(grant.label ? { label: grant.label } : {}), - ...(grant.proofKeyThumbprint - ? { proofKeyThumbprint: grant.proofKeyThumbprint } - : {}), - expiresAt: grant.expiresAt, - } satisfies BootstrapGrant, - }, - next, - ]; - }, - ); - - if (seededResult._tag === "success") { - return seededResult.grant; - } - if (seededResult.reason !== "not-found") { - return yield* seededResult.error; - } - // Only credentials in the issued pairing-code formats are case-insensitive. const pairingCredential = PAIRING_TOKEN_PATTERN.test(credential) ? credential.toUpperCase() diff --git a/apps/web/src/environments/primary/desktopAuth.ts b/apps/web/src/environments/primary/desktopAuth.ts index 325773d910d75..4a941c991ff4e 100644 --- a/apps/web/src/environments/primary/desktopAuth.ts +++ b/apps/web/src/environments/primary/desktopAuth.ts @@ -9,9 +9,8 @@ export function readDesktopPrimaryBearerToken(): Promise { return Promise.resolve(null); } - desktopBearerTokenPromise ??= bridge.getLocalEnvironmentBearerToken().catch((error) => { + desktopBearerTokenPromise ??= bridge.getLocalEnvironmentBearerToken().finally(() => { desktopBearerTokenPromise = null; - throw error; }); return desktopBearerTokenPromise; } diff --git a/apps/web/src/environments/primary/httpLayer.test.ts b/apps/web/src/environments/primary/httpLayer.test.ts index 43f72400eb92e..3d2e259567ac4 100644 --- a/apps/web/src/environments/primary/httpLayer.test.ts +++ b/apps/web/src/environments/primary/httpLayer.test.ts @@ -60,10 +60,13 @@ describe("primary environment HTTP layer", { concurrent: false }, () => { wsBaseUrl: "ws://127.0.0.1:3773", bootstrapToken: "desktop-bootstrap-token", }), - getLocalEnvironmentBearerToken: () => { - requested.resolve(undefined); - return bearerToken.promise; - }, + getLocalEnvironmentBearerToken: vi + .fn() + .mockImplementationOnce(() => { + requested.resolve(undefined); + return bearerToken.promise; + }) + .mockResolvedValueOnce("renewed-bearer-token"), } as unknown as DesktopBridge, }, }); @@ -80,6 +83,10 @@ describe("primary environment HTTP layer", { concurrent: false }, () => { const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); expect(request.credentials).not.toBe("include"); expect(request.headers.get("authorization")).toBe("Bearer desktop-bearer-token"); + + yield* HttpClient.get("http://127.0.0.1:3773/api/connect/link-state"); + const renewed = new Request(fetchMock.mock.calls[1]?.[0], fetchMock.mock.calls[1]?.[1]); + expect(renewed.headers.get("authorization")).toBe("Bearer renewed-bearer-token"); }).pipe(Effect.provide(PrimaryEnvironmentHttpLayer.layerForCurrentOrigin())); }); });