diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index b564916a9af0..d918dcf0613d 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -30,7 +30,10 @@ import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import type { PreparedHttpAuthorization } from "../connection/model.ts"; +import { + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, + type PreparedHttpAuthorization, +} from "../connection/model.ts"; export interface RelayEnvironmentAuthorization { readonly environmentId: EnvironmentId; @@ -66,7 +69,6 @@ export class RemoteEnvironmentAuthorization extends Context.Service< } >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} -const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; const CACHED_ENDPOINT_SOCKET_TIMEOUT_MS = 3_000; const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000; @@ -216,7 +218,7 @@ export const make = Effect.gen(function* () { Option.isSome(cached) && cached.value.environmentId === input.expectedEnvironmentId && cached.value.dpopThumbprint === thumbprint && - cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + cached.value.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS ) { yield* Effect.annotateCurrentSpan({ "connection.remote_token_cache": "hit", @@ -234,6 +236,7 @@ export const make = Effect.gen(function* () { httpAuthorization: { _tag: "Dpop" as const, accessToken: cached.value.accessToken, + expiresAtEpochMs: cached.value.expiresAtEpochMs, }, }; } @@ -305,6 +308,7 @@ export const make = Effect.gen(function* () { httpAuthorization: { _tag: "Dpop" as const, accessToken: token.accessToken, + expiresAtEpochMs: token.expiresAtEpochMs, }, }; }, diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index fbcb302ed135..e67e8bdf7493 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -103,6 +103,8 @@ export class ConnectionBlockedError extends Schema.TaggedErrorClass { httpAuthorization: { _tag: "Dpop" as const, accessToken: "dpop-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, }, }), ), @@ -394,6 +396,7 @@ describe("ConnectionResolver", () => { httpAuthorization: { _tag: "Dpop" as const, accessToken: "dpop-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, }, }), Effect.withSpan("test.remote.authorizeDpop"), diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index d9f54bb326ca..ca7f83a213a6 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -16,6 +16,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; import * as ConnectionDriver from "./driver.ts"; import { + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, @@ -1096,6 +1097,43 @@ describe("EnvironmentSupervisor", () => { }), ); + it.effect("renews a relay connection before its DPoP access token expires", () => + Effect.gen(function* () { + const tokenLifetimeMs = DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS * 2; + const harness = yield* makeHarness({ + prepare: (attempt) => + Effect.succeed({ + ...PREPARED_CONNECTION, + target: RELAY_TARGET, + httpAuthorization: { + _tag: "Dpop", + accessToken: `access-token-${attempt}`, + expiresAtEpochMs: tokenLifetimeMs * attempt, + }, + }), + }); + const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* TestClock.adjust(DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - 1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + + yield* TestClock.adjust(1); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect( + Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).httpAuthorization, + ).toMatchObject({ accessToken: "access-token-2" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("interrupts relay setup when credentials change", () => Effect.gen(function* () { const firstAttemptStarted = yield* Deferred.make(); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 85fda10ef1a7..a2dafa3ca65b 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -18,6 +18,7 @@ import type { ConnectionCatalogEntry } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; import * as ConnectionDriver from "./driver.ts"; import { + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, type ConnectionAttemptError, type ConnectionTarget, ConnectionTransientError, @@ -487,6 +488,21 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }); + const waitForAuthorizationRefresh = Effect.fnUntraced(function* ( + preparedConnection: PreparedConnection, + ) { + const authorization = preparedConnection.httpAuthorization; + if (authorization?._tag !== "Dpop") { + return yield* Effect.never; + } + const now = yield* Clock.currentTimeMillis; + yield* Effect.sleep( + Math.max(0, authorization.expiresAtEpochMs - now - DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS), + ); + yield* Effect.logDebug("Refreshing the environment connection before its DPoP token expires."); + return true; + }); + const runAttempt = Effect.fnUntraced(function* ( attempt: number, generation: number, @@ -584,7 +600,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( retryAt: null, }); - const connectedExit = yield* Effect.raceFirst( + const connectedExit = yield* Effect.raceAllFirst([ active.lease.session.closed.pipe( Effect.mapError( (error): TracedAttemptFailure => ({ @@ -601,7 +617,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }), ), ), - ).pipe(exitUnlessInterrupted); + waitForAuthorizationRefresh(active.lease.prepared), + ]).pipe(exitUnlessInterrupted); const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; if (Exit.isSuccess(connectedExit)) { return { diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts index 837bd2d5d301..1553bb34d00f 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts @@ -5,7 +5,10 @@ import * as Option from "effect/Option"; import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; -import { fetchEnvironmentPullRequestDiff } from "./pullRequestDiffHttp.ts"; +import { + fetchEnvironmentPullRequestDiff, + PullRequestDiffCredentialRejectedError, +} from "./pullRequestDiffHttp.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -77,4 +80,41 @@ describe("fetchEnvironmentPullRequestDiff", () => { }); }), ); + + it.effect("gives rejected diff sessions a recovery action", () => + Effect.gen(function* () { + const fetchFn = (() => + Promise.resolve( + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-auth-test", + }, + { status: 401 }, + ), + )) satisfies typeof fetch; + + const error = yield* fetchEnvironmentPullRequestDiff({ + prepared: PREPARED, + signer: Option.none(), + diff: { + projectId: ProjectId.make("project-1"), + repository: "owner/repository", + number: 42, + }, + }).pipe(Effect.provide(remoteHttpClientLayer(fetchFn)), Effect.flip); + + expect(error).toBeInstanceOf(PullRequestDiffCredentialRejectedError); + expect(error).toMatchObject({ + repository: "owner/repository", + number: 42, + traceId: "trace-auth-test", + }); + expect(error.message).toBe( + "This environment session is no longer valid (invalid_credential). Refresh the page or quit and reopen T3 Code.", + ); + }), + ); }); diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.ts index 48304663cd98..b6b925917b99 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.ts @@ -1,8 +1,13 @@ -import type { PullRequestDiffInput, PullRequestDiffResult } from "@t3tools/contracts"; +import { + EnvironmentAuthInvalidError, + type PullRequestDiffInput, + type PullRequestDiffResult, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import type { PreparedConnection } from "../connection/model.ts"; @@ -17,6 +22,24 @@ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./envir const DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS = 60_000; +export class PullRequestDiffCredentialRejectedError extends Schema.TaggedErrorClass()( + "PullRequestDiffCredentialRejectedError", + { + repository: Schema.String, + number: Schema.Number, + traceId: Schema.String, + cause: EnvironmentAuthInvalidError, + }, +) { + override get message(): string { + return "This environment session is no longer valid (invalid_credential). Refresh the page or quit and reopen T3 Code."; + } +} + +export type PullRequestDiffLoadError = + | RemoteEnvironmentRequestError + | PullRequestDiffCredentialRejectedError; + export const fetchEnvironmentPullRequestDiff = Effect.fn( "clientRuntime.state.fetchEnvironmentPullRequestDiff", )(function* (input: { @@ -42,6 +65,17 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn( input.prepared.httpAuthorization, client.pullRequests.diff({ payload: input.diff, headers }), ), + ).pipe( + Effect.mapError((error) => + error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential" + ? new PullRequestDiffCredentialRejectedError({ + repository: input.diff.repository, + number: input.diff.number, + traceId: error.traceId, + cause: error, + }) + : error, + ), ); }); @@ -51,7 +85,7 @@ export class PullRequestDiffLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, input: PullRequestDiffInput, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/client-runtime/state/pullRequestDiffHttp/PullRequestDiffLoader") {} diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index b74553e57a9e..44f138abc4bf 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -20,7 +20,12 @@ import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; -export { PullRequestDiffLoader, pullRequestDiffLoaderLayer } from "./pullRequestDiffHttp.ts"; +export { + type PullRequestDiffLoadError, + PullRequestDiffCredentialRejectedError, + PullRequestDiffLoader, + pullRequestDiffLoaderLayer, +} from "./pullRequestDiffHttp.ts"; export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( "EnvironmentHttpConnectionNotReadyError",