Skip to content
Merged
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
10 changes: 7 additions & 3 deletions packages/client-runtime/src/authorization/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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",
Expand All @@ -234,6 +236,7 @@ export const make = Effect.gen(function* () {
httpAuthorization: {
_tag: "Dpop" as const,
accessToken: cached.value.accessToken,
expiresAtEpochMs: cached.value.expiresAtEpochMs,
},
};
}
Expand Down Expand Up @@ -305,6 +308,7 @@ export const make = Effect.gen(function* () {
httpAuthorization: {
_tag: "Dpop" as const,
accessToken: token.accessToken,
expiresAtEpochMs: token.expiresAtEpochMs,
},
};
},
Expand Down
3 changes: 3 additions & 0 deletions packages/client-runtime/src/connection/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export class ConnectionBlockedError extends Schema.TaggedErrorClass<ConnectionBl

export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError;

export const DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;

export type PreparedHttpAuthorization =
| {
readonly _tag: "Bearer";
Expand All @@ -111,6 +113,7 @@ export type PreparedHttpAuthorization =
| {
readonly _tag: "Dpop";
readonly accessToken: string;
readonly expiresAtEpochMs: number;
};

export interface PreparedConnection {
Expand Down
3 changes: 3 additions & 0 deletions packages/client-runtime/src/connection/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
httpAuthorization: {
_tag: "Dpop" as const,
accessToken: "dpop-access-token",
expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
},
}),
)),
Expand Down Expand Up @@ -357,6 +358,7 @@ describe("ConnectionResolver", () => {
httpAuthorization: {
_tag: "Dpop" as const,
accessToken: "dpop-access-token",
expiresAtEpochMs: Number.MAX_SAFE_INTEGER,
},
}),
),
Expand Down Expand Up @@ -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"),
Expand Down
38 changes: 38 additions & 0 deletions packages/client-runtime/src/connection/supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>();
Expand Down
21 changes: 19 additions & 2 deletions packages/client-runtime/src/connection/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 => ({
Expand All @@ -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 {
Expand Down
42 changes: 41 additions & 1 deletion packages/client-runtime/src/state/pullRequestDiffHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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.",
);
}),
);
});
38 changes: 36 additions & 2 deletions packages/client-runtime/src/state/pullRequestDiffHttp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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>()(
"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: {
Expand All @@ -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,
),
);
});

Expand All @@ -51,7 +85,7 @@ export class PullRequestDiffLoader extends Context.Service<
readonly load: (
prepared: PreparedConnection,
input: PullRequestDiffInput,
) => Effect.Effect<PullRequestDiffResult, RemoteEnvironmentRequestError>;
) => Effect.Effect<PullRequestDiffResult, PullRequestDiffLoadError>;
}
>()("@t3tools/client-runtime/state/pullRequestDiffHttp/PullRequestDiffLoader") {}

Expand Down
7 changes: 6 additions & 1 deletion packages/client-runtime/src/state/pullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading