authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-02 15:20:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-02 15:20:30-07:00
loga04c09a190ac78e23ca63971067ff0fc2417bcca
tree46986704d13b6ab819d3aea5426049a2eaf8c15f
parente3f14058847dbd36695277aab941fefa94e21e5f
signature Signed by PGP key B5690EEEBB952194

Use HttpApi for Environment APIs & standardize authn/authz (#2858)

Co-authored-by: codex <codex@users.noreply.github.com>

102 files changed, 6822 insertions(+), 4876 deletions(-)

apps/desktop/package.json+1
......@@ -16,6 +16,7 @@
1616 },
1717 "dependencies": {
1818 "@effect/platform-node": "catalog:",
19 "@t3tools/client-runtime": "workspace:*",
1920 "@t3tools/contracts": "workspace:*",
2021 "@t3tools/shared": "workspace:*",
2122 "@t3tools/ssh": "workspace:*",
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2-2
......@@ -22,7 +22,7 @@ import {
2222 ensureSshEnvironment,
2323 fetchSshEnvironmentDescriptor,
2424 fetchSshSessionState,
25 issueSshWebSocketToken,
25 issueSshWebSocketTicket,
2626 resolveSshPasswordPrompt,
2727} from "./methods/sshEnvironment.ts";
2828import {
......@@ -62,7 +62,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
6262 yield* ipc.handle(fetchSshEnvironmentDescriptor);
6363 yield* ipc.handle(bootstrapSshBearerSession);
6464 yield* ipc.handle(fetchSshSessionState);
65 yield* ipc.handle(issueSshWebSocketToken);
65 yield* ipc.handle(issueSshWebSocketTicket);
6666 yield* ipc.handle(resolveSshPasswordPrompt);
6767
6868 yield* ipc.handle(getServerExposureState);
apps/desktop/src/ipc/methods/sshEnvironment.test.ts created+115
......@@ -0,0 +1,115 @@
1import { assert, describe, it } from "@effect/vitest";
2import { SshHttpBridgeError } from "@t3tools/ssh/errors";
3import * as Cause from "effect/Cause";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
9
10import {
11 DesktopSshEnvironmentRequestError,
12 fetchSshEnvironmentDescriptor,
13} from "./sshEnvironment.ts";
14
15function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
16 return HttpClientResponse.fromWeb(
17 request,
18 new Response(JSON.stringify(body), {
19 status,
20 headers: { "content-type": "application/json" },
21 }),
22 );
23}
24
25function makeHttpClientLayer(
26 handler: (
27 request: HttpClientRequest.HttpClientRequest,
28 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>,
29) {
30 return Layer.succeed(
31 HttpClient.HttpClient,
32 HttpClient.make((request) => handler(request)),
33 );
34}
35
36describe("SSH environment IPC", () => {
37 it.effect("fetches and decodes the remote environment descriptor", () => {
38 const requestUrls: string[] = [];
39 const layer = makeHttpClientLayer((request) =>
40 Effect.sync(() => {
41 requestUrls.push(request.url);
42 return jsonResponse(request, {
43 environmentId: "remote-env",
44 label: "Remote Devbox",
45 platform: { os: "linux", arch: "x64" },
46 serverVersion: "1.2.3",
47 capabilities: { repositoryIdentity: true },
48 });
49 }),
50 );
51
52 return Effect.gen(function* () {
53 const descriptor = yield* fetchSshEnvironmentDescriptor.handler({
54 httpBaseUrl: "http://127.0.0.1:41773/",
55 });
56
57 assert.deepEqual(descriptor, {
58 environmentId: "remote-env",
59 label: "Remote Devbox",
60 platform: { os: "linux", arch: "x64" },
61 serverVersion: "1.2.3",
62 capabilities: { repositoryIdentity: true },
63 });
64 assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]);
65 }).pipe(Effect.provide(layer));
66 });
67
68 it.effect("wraps schema decode failures in a typed request error", () => {
69 const layer = makeHttpClientLayer((request) =>
70 Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
71 );
72
73 return Effect.gen(function* () {
74 const exit = yield* Effect.exit(
75 fetchSshEnvironmentDescriptor.handler({
76 httpBaseUrl: "http://127.0.0.1:41773/",
77 }),
78 );
79 assert(Exit.isFailure(exit));
80 const failure = Cause.findErrorOption(exit.cause);
81 assert(Option.isSome(failure));
82 const error = failure.value;
83
84 assert.instanceOf(error, DesktopSshEnvironmentRequestError);
85 assert.equal(error.operation, "fetch-environment-descriptor");
86 assert.equal(error.cause instanceof SshHttpBridgeError, false);
87 }).pipe(Effect.provide(layer));
88 });
89
90 it.effect("rejects non-loopback HTTP endpoints before issuing a request", () => {
91 let requestCount = 0;
92 const layer = makeHttpClientLayer((request) =>
93 Effect.sync(() => {
94 requestCount += 1;
95 return jsonResponse(request, {});
96 }),
97 );
98
99 return Effect.gen(function* () {
100 const exit = yield* Effect.exit(
101 fetchSshEnvironmentDescriptor.handler({
102 httpBaseUrl: "http://remote.example.com:41773/",
103 }),
104 );
105 assert(Exit.isFailure(exit));
106 const failure = Cause.findErrorOption(exit.cause);
107 assert(Option.isSome(failure));
108 const error = failure.value;
109
110 assert.instanceOf(error, DesktopSshEnvironmentRequestError);
111 assert.instanceOf(error.cause, SshHttpBridgeError);
112 assert.equal(requestCount, 0);
113 }).pipe(Effect.provide(layer));
114 });
115});
apps/desktop/src/ipc/methods/sshEnvironment.ts+63-15
......@@ -1,3 +1,9 @@
1import {
2 bootstrapRemoteBearerSession,
3 fetchRemoteEnvironmentDescriptor,
4 fetchRemoteSessionState,
5 issueRemoteWebSocketTicket,
6} from "@t3tools/client-runtime";
17import {
28 DesktopDiscoveredSshHostSchema,
39 DesktopSshBearerBootstrapInputSchema,
......@@ -9,10 +15,12 @@ import {
915 DesktopSshPasswordPromptCancelledType,
1016 DesktopSshPasswordPromptResolutionInputSchema,
1117 ExecutionEnvironmentDescriptor,
12 AuthBearerBootstrapResult,
18 AuthAccessTokenResult,
1319 AuthSessionState,
14 AuthWebSocketTokenResult,
20 AuthWebSocketTicketResult,
1521} from "@t3tools/contracts";
22import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel";
23import * as Data from "effect/Data";
1624import * as Effect from "effect/Effect";
1725import * as Schema from "effect/Schema";
1826
......@@ -20,7 +28,34 @@ import * as IpcChannels from "../channels.ts";
2028import { makeIpcMethod } from "../DesktopIpc.ts";
2129import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
2230import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";
23import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts";
31
32type DesktopSshEnvironmentRequestOperation =
33 | "fetch-environment-descriptor"
34 | "bootstrap-bearer-session"
35 | "fetch-session-state"
36 | "issue-websocket-ticket";
37
38export class DesktopSshEnvironmentRequestError extends Data.TaggedError(
39 "DesktopSshEnvironmentRequestError",
40)<{
41 readonly operation: DesktopSshEnvironmentRequestOperation;
42 readonly cause: unknown;
43}> {
44 override get message() {
45 return `SSH remote API request failed during ${this.operation}.`;
46 }
47}
48
49const withLoopbackSshApi =
50 <A, E, R>(
51 operation: DesktopSshEnvironmentRequestOperation,
52 use: (httpBaseUrl: string) => Effect.Effect<A, E, R>,
53 ) =>
54 (httpBaseUrl: string): Effect.Effect<A, DesktopSshEnvironmentRequestError, R> =>
55 resolveLoopbackSshHttpBaseUrl(httpBaseUrl).pipe(
56 Effect.flatMap(use),
57 Effect.mapError((cause) => new DesktopSshEnvironmentRequestError({ operation, cause })),
58 );
2459
2560export const discoverSshHosts = makeIpcMethod({
2661 channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL,
......@@ -69,21 +104,26 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({
69104 payload: DesktopSshHttpBaseUrlInputSchema,
70105 result: ExecutionEnvironmentDescriptor,
71106 handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) {
72 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
73 return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl });
107 return yield* withLoopbackSshApi("fetch-environment-descriptor", (resolvedHttpBaseUrl) =>
108 fetchRemoteEnvironmentDescriptor({ httpBaseUrl: resolvedHttpBaseUrl }),
109 )(httpBaseUrl);
74110 }),
75111});
76112
77113export const bootstrapSshBearerSession = makeIpcMethod({
78114 channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
79115 payload: DesktopSshBearerBootstrapInputSchema,
80 result: AuthBearerBootstrapResult,
116 result: AuthAccessTokenResult,
81117 handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({
82118 httpBaseUrl,
83119 credential,
84120 }) {
85 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
86 return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential });
121 return yield* withLoopbackSshApi("bootstrap-bearer-session", (resolvedHttpBaseUrl) =>
122 bootstrapRemoteBearerSession({
123 httpBaseUrl: resolvedHttpBaseUrl,
124 credential,
125 }),
126 )(httpBaseUrl);
87127 }),
88128});
89129
......@@ -95,21 +135,29 @@ export const fetchSshSessionState = makeIpcMethod({
95135 httpBaseUrl,
96136 bearerToken,
97137 }) {
98 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
99 return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken });
138 return yield* withLoopbackSshApi("fetch-session-state", (resolvedHttpBaseUrl) =>
139 fetchRemoteSessionState({
140 httpBaseUrl: resolvedHttpBaseUrl,
141 bearerToken,
142 }),
143 )(httpBaseUrl);
100144 }),
101145});
102146
103export const issueSshWebSocketToken = makeIpcMethod({
147export const issueSshWebSocketTicket = makeIpcMethod({
104148 channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
105149 payload: DesktopSshBearerRequestInputSchema,
106 result: AuthWebSocketTokenResult,
107 handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({
150 result: AuthWebSocketTicketResult,
151 handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({
108152 httpBaseUrl,
109153 bearerToken,
110154 }) {
111 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
112 return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken });
155 return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) =>
156 issueRemoteWebSocketTicket({
157 httpBaseUrl: resolvedHttpBaseUrl,
158 bearerToken,
159 }),
160 )(httpBaseUrl);
113161 }),
114162});
115163
apps/desktop/src/main.ts+1-2
......@@ -40,7 +40,6 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
4040import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
4141import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
4242import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
43import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts";
4443import * as DesktopState from "./app/DesktopState.ts";
4544import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
4645import * as DesktopWindow from "./window/DesktopWindow.ts";
......@@ -116,7 +115,7 @@ const desktopFoundationLayer = Layer.mergeAll(
116115 DesktopObservability.layer,
117116).pipe(Layer.provideMerge(desktopEnvironmentLayer));
118117
119const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe(
118const desktopSshLayer = desktopSshEnvironmentLayer.pipe(
120119 Layer.provideMerge(DesktopSshPasswordPrompts.layer()),
121120);
122121
apps/desktop/src/preload.ts+1-1
......@@ -66,7 +66,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
6666 }),
6767 fetchSshSessionState: (httpBaseUrl, bearerToken) =>
6868 ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }),
69 issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>
69 issueSshWebSocketTicket: (httpBaseUrl, bearerToken) =>
7070 ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }),
7171 onSshPasswordPrompt: (listener) => {
7272 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts deleted-79
......@@ -1,79 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import { SshHttpBridgeError } from "@t3tools/ssh/errors";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
6
7import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts";
8
9function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
10 return HttpClientResponse.fromWeb(
11 request,
12 new Response(JSON.stringify(body), {
13 status,
14 headers: { "content-type": "application/json" },
15 }),
16 );
17}
18
19function makeLayer(
20 handler: (
21 request: HttpClientRequest.HttpClientRequest,
22 ) => Effect.Effect<HttpClientResponse.HttpClientResponse>,
23) {
24 return DesktopSshRemoteApi.layer.pipe(
25 Layer.provide(
26 Layer.succeed(
27 HttpClient.HttpClient,
28 HttpClient.make((request) => handler(request)),
29 ),
30 ),
31 );
32}
33
34describe("DesktopSshRemoteApi", () => {
35 it.effect("fetches and decodes the remote environment descriptor", () => {
36 const requestUrls: string[] = [];
37 const layer = makeLayer((request) =>
38 Effect.sync(() => {
39 requestUrls.push(request.url);
40 return jsonResponse(request, {
41 environmentId: "remote-env",
42 label: "Remote Devbox",
43 platform: { os: "linux", arch: "x64" },
44 serverVersion: "1.2.3",
45 capabilities: { repositoryIdentity: true },
46 });
47 }),
48 );
49
50 return Effect.gen(function* () {
51 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
52 const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({
53 httpBaseUrl: "http://127.0.0.1:41773/",
54 });
55
56 assert.equal(descriptor.label, "Remote Devbox");
57 assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]);
58 }).pipe(Effect.provide(layer));
59 });
60
61 it.effect("wraps schema decode failures in a typed remote api error", () => {
62 const layer = makeLayer((request) =>
63 Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
64 );
65
66 return Effect.gen(function* () {
67 const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
68 const error = yield* remoteApi
69 .fetchEnvironmentDescriptor({
70 httpBaseUrl: "http://127.0.0.1:41773/",
71 })
72 .pipe(Effect.flip);
73
74 assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError);
75 assert.equal(error.operation, "fetch-environment-descriptor");
76 assert.equal(error.cause instanceof SshHttpBridgeError, false);
77 }).pipe(Effect.provide(layer));
78 });
79});
apps/desktop/src/ssh/DesktopSshRemoteApi.ts deleted-124
......@@ -1,124 +0,0 @@
1import {
2 AuthBearerBootstrapResult,
3 AuthSessionState,
4 AuthWebSocketTokenResult,
5 type AuthBearerBootstrapResult as AuthBearerBootstrapResultType,
6 type AuthSessionState as AuthSessionStateType,
7 type AuthWebSocketTokenResult as AuthWebSocketTokenResultType,
8 ExecutionEnvironmentDescriptor,
9 type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType,
10} from "@t3tools/contracts";
11import { SshHttpBridgeError } from "@t3tools/ssh/errors";
12import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel";
13import * as Context from "effect/Context";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";
18import { HttpClient } from "effect/unstable/http";
19
20export type DesktopSshRemoteApiOperation =
21 | "fetch-environment-descriptor"
22 | "bootstrap-bearer-session"
23 | "fetch-session-state"
24 | "issue-websocket-token";
25
26export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{
27 readonly operation: DesktopSshRemoteApiOperation;
28 readonly cause: SshHttpBridgeError | Schema.SchemaError;
29}> {
30 override get message() {
31 return `SSH remote API request failed during ${this.operation}.`;
32 }
33}
34
35export interface DesktopSshRemoteApiShape {
36 readonly fetchEnvironmentDescriptor: (input: {
37 readonly httpBaseUrl: string;
38 }) => Effect.Effect<ExecutionEnvironmentDescriptorType, DesktopSshRemoteApiError>;
39 readonly bootstrapBearerSession: (input: {
40 readonly httpBaseUrl: string;
41 readonly credential: string;
42 }) => Effect.Effect<AuthBearerBootstrapResultType, DesktopSshRemoteApiError>;
43 readonly fetchSessionState: (input: {
44 readonly httpBaseUrl: string;
45 readonly bearerToken: string;
46 }) => Effect.Effect<AuthSessionStateType, DesktopSshRemoteApiError>;
47 readonly issueWebSocketToken: (input: {
48 readonly httpBaseUrl: string;
49 readonly bearerToken: string;
50 }) => Effect.Effect<AuthWebSocketTokenResultType, DesktopSshRemoteApiError>;
51}
52
53export class DesktopSshRemoteApi extends Context.Service<
54 DesktopSshRemoteApi,
55 DesktopSshRemoteApiShape
56>()("@t3tools/desktop/ssh/DesktopSshRemoteApi") {}
57
58const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect(
59 ExecutionEnvironmentDescriptor,
60);
61const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult);
62const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState);
63const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult);
64
65const mapError =
66 (operation: DesktopSshRemoteApiOperation) =>
67 (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError =>
68 new DesktopSshRemoteApiError({ operation, cause });
69
70const make = Effect.gen(function* () {
71 const httpClient = yield* HttpClient.HttpClient;
72 const provideHttpClient = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
73 effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
74
75 return DesktopSshRemoteApi.of({
76 fetchEnvironmentDescriptor: ({ httpBaseUrl }) =>
77 fetchLoopbackSshJson<unknown>({
78 httpBaseUrl,
79 pathname: "/.well-known/t3/environment",
80 }).pipe(
81 Effect.flatMap(decodeExecutionEnvironmentDescriptor),
82 Effect.mapError(mapError("fetch-environment-descriptor")),
83 provideHttpClient,
84 Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"),
85 ),
86 bootstrapBearerSession: ({ httpBaseUrl, credential }) =>
87 fetchLoopbackSshJson<unknown>({
88 httpBaseUrl,
89 pathname: "/api/auth/bootstrap/bearer",
90 method: "POST",
91 body: { credential },
92 }).pipe(
93 Effect.flatMap(decodeAuthBearerBootstrapResult),
94 Effect.mapError(mapError("bootstrap-bearer-session")),
95 provideHttpClient,
96 Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"),
97 ),
98 fetchSessionState: ({ httpBaseUrl, bearerToken }) =>
99 fetchLoopbackSshJson<unknown>({
100 httpBaseUrl,
101 pathname: "/api/auth/session",
102 bearerToken,
103 }).pipe(
104 Effect.flatMap(decodeAuthSessionState),
105 Effect.mapError(mapError("fetch-session-state")),
106 provideHttpClient,
107 Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"),
108 ),
109 issueWebSocketToken: ({ httpBaseUrl, bearerToken }) =>
110 fetchLoopbackSshJson<unknown>({
111 httpBaseUrl,
112 pathname: "/api/auth/ws-token",
113 method: "POST",
114 bearerToken,
115 }).pipe(
116 Effect.flatMap(decodeAuthWebSocketTokenResult),
117 Effect.mapError(mapError("issue-websocket-token")),
118 provideHttpClient,
119 Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"),
120 ),
121 });
122});
123
124export const layer = Layer.effect(DesktopSshRemoteApi, make);
apps/mobile/src/lib/connection.test.ts+15-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it, vi } from "vitest";
22
3import { redactPairingCredential } from "./connection";
3import { mobileAuthClientMetadata, redactPairingCredential } from "./connection";
44
55vi.mock("./runtime", () => ({
66 mobileRemoteHttpRuntime: {
......@@ -8,7 +8,21 @@ vi.mock("./runtime", () => ({
88 },
99}));
1010
11vi.mock("react-native", () => ({
12 Platform: {
13 OS: "ios",
14 },
15}));
16
1117describe("mobile remote connection records", () => {
18 it("identifies mobile token exchanges for authorized-client presentation", () => {
19 expect(mobileAuthClientMetadata()).toEqual({
20 label: "T3 Code Mobile",
21 deviceType: "mobile",
22 os: "iOS",
23 });
24 });
25
1226 it("removes one-time bootstrap credentials before persisting pairing URLs", () => {
1327 expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe(
1428 "https://desktop.example/",
apps/mobile/src/lib/connection.ts+12-2
......@@ -1,9 +1,10 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts";
22import {
33 bootstrapRemoteBearerSession,
44 fetchRemoteEnvironmentDescriptor,
55} from "@t3tools/client-runtime";
66import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7import { Platform } from "react-native";
78import { mobileRemoteHttpRuntime } from "./runtime";
89
910export interface RemoteConnectionInput {
......@@ -36,6 +37,14 @@ export function redactPairingCredential(pairingUrl: string): string {
3637 }
3738}
3839
40export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {
41 return {
42 label: "T3 Code Mobile",
43 deviceType: "mobile",
44 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
45 };
46}
47
3948export async function bootstrapRemoteConnection(
4049 input: RemoteConnectionInput,
4150): Promise<SavedRemoteConnection> {
......@@ -53,6 +62,7 @@ export async function bootstrapRemoteConnection(
5362 bootstrapRemoteBearerSession({
5463 httpBaseUrl: target.httpBaseUrl,
5564 credential: target.credential,
65 clientMetadata: mobileAuthClientMetadata(),
5666 }),
5767 );
5868
......@@ -63,6 +73,6 @@ export async function bootstrapRemoteConnection(
6373 displayUrl: target.httpBaseUrl,
6474 httpBaseUrl: target.httpBaseUrl,
6575 wsBaseUrl: target.wsBaseUrl,
66 bearerToken: bootstrap.sessionToken,
76 bearerToken: bootstrap.access_token,
6777 };
6878}
apps/server/src/auth/EnvironmentAuth.test.ts created+261
......@@ -0,0 +1,261 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AuthAdministrativeScopes } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6
7import type { ServerConfigShape } from "../config.ts";
8import { ServerConfig } from "../config.ts";
9import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
10import * as PairingGrantStore from "./PairingGrantStore.ts";
11import * as EnvironmentAuth from "./EnvironmentAuth.ts";
12
13import * as ServerSecretStore from "./ServerSecretStore.ts";
14
15const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) =>
16 Layer.effect(
17 ServerConfig,
18 Effect.gen(function* () {
19 const config = yield* ServerConfig;
20 return {
21 ...config,
22 ...overrides,
23 } satisfies ServerConfigShape;
24 }),
25 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
26
27const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfigShape>) =>
28 EnvironmentAuth.layer.pipe(
29 Layer.provide(SqlitePersistenceMemory),
30 Layer.provide(ServerSecretStore.layer),
31 Layer.provide(makeServerConfigLayer(overrides)),
32 );
33
34const makeCookieRequest = (
35 sessionToken: string,
36): Parameters<EnvironmentAuth.EnvironmentAuthShape["authenticateHttpRequest"]>[0] =>
37 ({
38 cookies: {
39 t3_session: sessionToken,
40 },
41 headers: {},
42 }) as unknown as Parameters<EnvironmentAuth.EnvironmentAuthShape["authenticateHttpRequest"]>[0];
43
44const requestMetadata = {
45 deviceType: "desktop" as const,
46 os: "macOS",
47 browser: "Chrome",
48 ipAddress: "192.168.1.23",
49};
50
51it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
52 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
53 Effect.sync(() => {
54 const error = EnvironmentAuth.toBootstrapExchangeError(
55 new PairingGrantStore.BootstrapCredentialInvalidError({
56 message: "Unknown bootstrap credential.",
57 }),
58 );
59
60 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
61 if (error._tag === "ServerAuthInvalidCredentialError") {
62 expect(error.reason).toBe("invalid_credential");
63 }
64 }),
65 );
66
67 it.effect("maps unexpected bootstrap failures to 500", () =>
68 Effect.sync(() => {
69 const error = EnvironmentAuth.toBootstrapExchangeError(
70 new PairingGrantStore.BootstrapCredentialInternalError({
71 message: "Failed to consume bootstrap credential.",
72 cause: new Error("sqlite is unavailable"),
73 }),
74 );
75
76 expect(error._tag).toBe("ServerAuthInternalError");
77 expect(error.message).toBe("Failed to validate bootstrap credential.");
78 }),
79 );
80
81 it.effect("issues standard pairing credentials by default", () =>
82 Effect.gen(function* () {
83 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
84
85 const pairingCredential = yield* serverAuth.issuePairingCredential();
86 const exchanged = yield* serverAuth.createBrowserSession(
87 pairingCredential.credential,
88 requestMetadata,
89 );
90 const verified = yield* serverAuth.authenticateHttpRequest(
91 makeCookieRequest(exchanged.sessionToken),
92 );
93
94 expect(verified.sessionId.length).toBeGreaterThan(0);
95 expect(verified.scopes).toEqual([
96 "orchestration:read",
97 "orchestration:operate",
98 "terminal:operate",
99 "review:write",
100 "relay:read",
101 ]);
102 expect(verified.subject).toBe("one-time-token");
103 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
104 );
105
106 it.effect("does not exchange ordinary pairing grants for administrative access tokens", () =>
107 Effect.gen(function* () {
108 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
109 const pairingCredential = yield* serverAuth.issuePairingCredential();
110
111 const error = yield* serverAuth
112 .exchangeBootstrapCredentialForAccessToken(
113 pairingCredential.credential,
114 ["orchestration:read", "access:write"],
115 requestMetadata,
116 )
117 .pipe(Effect.flip);
118
119 expect(error._tag).toBe("ServerAuthInvalidRequestError");
120 if (error._tag === "ServerAuthInvalidRequestError") {
121 expect(error.reason).toBe("scope_not_granted");
122 }
123 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
124 );
125
126 it.effect("inherits a constrained pairing grant when token exchange omits scope", () =>
127 Effect.gen(function* () {
128 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
129 const pairingCredential = yield* serverAuth.issuePairingCredential({
130 scopes: ["orchestration:read"],
131 });
132
133 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
134 pairingCredential.credential,
135 undefined,
136 requestMetadata,
137 );
138
139 expect(token.scope).toBe("orchestration:read");
140 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
141 );
142
143 it.effect("keeps user-issued administrative pairing links manageable", () =>
144 Effect.gen(function* () {
145 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
146 const pairingCredential = yield* serverAuth.issuePairingCredential({
147 scopes: AuthAdministrativeScopes,
148 });
149 const listedPairingLinks = yield* serverAuth.listPairingLinks();
150
151 expect(
152 listedPairingLinks.find((pairingLink) => pairingLink.id === pairingCredential.id)?.subject,
153 ).toBe("one-time-token");
154 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
155 );
156
157 it.effect("issues startup pairing URLs that bootstrap administrative sessions", () =>
158 Effect.gen(function* () {
159 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
160
161 const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
162 const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
163 const listedPairingLinks = yield* serverAuth.listPairingLinks();
164 expect(token).toBeTruthy();
165 expect(
166 listedPairingLinks.some(
167 (pairingLink) => pairingLink.subject === "administrative-bootstrap",
168 ),
169 ).toBe(false);
170
171 const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata);
172 const verified = yield* serverAuth.authenticateHttpRequest(
173 makeCookieRequest(exchanged.sessionToken),
174 );
175
176 expect(verified.scopes).toEqual([
177 "orchestration:read",
178 "orchestration:operate",
179 "terminal:operate",
180 "review:write",
181 "relay:read",
182 "access:read",
183 "access:write",
184 "relay:write",
185 ]);
186 expect(verified.subject).toBe("administrative-bootstrap");
187 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
188 );
189
190 it.effect(
191 "lists pairing links and revokes other sessions while keeping the administrative session",
192 () =>
193 Effect.gen(function* () {
194 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
195
196 const administrativeExchange = yield* serverAuth.createBrowserSession(
197 "desktop-bootstrap-token",
198 requestMetadata,
199 );
200 const administrativeSession = yield* serverAuth.authenticateHttpRequest(
201 makeCookieRequest(administrativeExchange.sessionToken),
202 );
203 const pairingCredential = yield* serverAuth.issuePairingCredential({
204 label: "Julius iPhone",
205 });
206 const listedPairingLinks = yield* serverAuth.listPairingLinks();
207 const clientExchange = yield* serverAuth.createBrowserSession(
208 pairingCredential.credential,
209 {
210 ...requestMetadata,
211 deviceType: "mobile",
212 os: "iOS",
213 browser: "Safari",
214 ipAddress: "192.168.1.88",
215 },
216 );
217 const clientSession = yield* serverAuth.authenticateHttpRequest(
218 makeCookieRequest(clientExchange.sessionToken),
219 );
220 const clientsBeforeRevoke = yield* serverAuth.listClientSessions(
221 administrativeSession.sessionId,
222 );
223 const revokedCount = yield* serverAuth.revokeOtherClientSessions(
224 administrativeSession.sessionId,
225 );
226 const clientsAfterRevoke = yield* serverAuth.listClientSessions(
227 administrativeSession.sessionId,
228 );
229
230 expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id);
231 expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe(
232 "Julius iPhone",
233 );
234 expect(clientsBeforeRevoke).toHaveLength(2);
235 expect(
236 clientsBeforeRevoke.find((entry) => entry.sessionId === administrativeSession.sessionId)
237 ?.current,
238 ).toBe(true);
239 expect(
240 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current,
241 ).toBe(false);
242 expect(
243 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
244 .label,
245 ).toBe("Julius iPhone");
246 expect(
247 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
248 .deviceType,
249 ).toBe("mobile");
250 expect(revokedCount).toBe(1);
251 expect(clientsAfterRevoke).toHaveLength(1);
252 expect(clientsAfterRevoke[0]?.sessionId).toBe(administrativeSession.sessionId);
253 }).pipe(
254 Effect.provide(
255 makeEnvironmentAuthLayer({
256 desktopBootstrapToken: "desktop-bootstrap-token",
257 }),
258 ),
259 ),
260 );
261});
apps/server/src/auth/EnvironmentAuth.ts created+662
......@@ -0,0 +1,662 @@
1import {
2 AuthAccessTokenType,
3 AuthAccessWriteScope,
4 AuthAdministrativeScopes,
5 AuthStandardClientScopes,
6 type AuthAccessTokenResult,
7 type AuthBrowserSessionResult,
8 type AuthClientMetadata,
9 type AuthClientSession,
10 type AuthCreatePairingCredentialInput,
11 type AuthEnvironmentScope,
12 type AuthPairingLink,
13 type AuthPairingCredentialResult,
14 type AuthSessionId,
15 type AuthSessionState,
16 type ServerAuthDescriptor,
17 type ServerAuthSessionMethod,
18 type AuthWebSocketTicketResult,
19} from "@t3tools/contracts";
20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
21import * as Context from "effect/Context";
22import * as Data from "effect/Data";
23import * as DateTime from "effect/DateTime";
24import * as Duration from "effect/Duration";
25import * as Effect from "effect/Effect";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
29
30import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
31import * as PairingGrantStore from "./PairingGrantStore.ts";
32import * as ServerSecretStore from "./ServerSecretStore.ts";
33import * as SessionStore from "./SessionStore.ts";
34import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
35
36export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
37export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
38
39export interface IssuedPairingLink {
40 readonly id: string;
41 readonly credential: string;
42 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43 readonly subject: string;
44 readonly label?: string;
45 readonly createdAt: DateTime.Utc;
46 readonly expiresAt: DateTime.Utc;
47}
48
49export interface IssuedBearerSession {
50 readonly sessionId: AuthSessionId;
51 readonly token: string;
52 readonly method: "bearer-access-token";
53 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
54 readonly subject: string;
55 readonly client: AuthClientMetadata;
56 readonly expiresAt: DateTime.Utc;
57}
58
59export interface AuthenticatedSession {
60 readonly sessionId: AuthSessionId;
61 readonly subject: string;
62 readonly method: ServerAuthSessionMethod;
63 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
64 readonly expiresAt?: DateTime.DateTime;
65}
66
67export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{
68 readonly message: string;
69 readonly cause?: unknown;
70}> {}
71
72export class ServerAuthInvalidCredentialError extends Data.TaggedError(
73 "ServerAuthInvalidCredentialError",
74)<{
75 readonly reason: "missing_credential" | "invalid_credential";
76 readonly cause?: unknown;
77}> {}
78
79export class ServerAuthInvalidRequestError extends Data.TaggedError(
80 "ServerAuthInvalidRequestError",
81)<{
82 readonly reason: "invalid_scope" | "scope_not_granted";
83}> {}
84
85export class ServerAuthForbiddenOperationError extends Data.TaggedError(
86 "ServerAuthForbiddenOperationError",
87)<{
88 readonly reason: "current_session_revoke_not_allowed";
89}> {}
90
91export interface EnvironmentAuthShape {
92 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
93 readonly getSessionState: (
94 request: HttpServerRequest.HttpServerRequest,
95 ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>;
96 readonly createBrowserSession: (
97 credential: string,
98 requestMetadata: AuthClientMetadata,
99 ) => Effect.Effect<
100 {
101 readonly response: AuthBrowserSessionResult;
102 readonly sessionToken: string;
103 },
104 ServerAuthInvalidCredentialError | ServerAuthInternalError
105 >;
106 readonly exchangeBootstrapCredentialForAccessToken: (
107 credential: string,
108 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
109 requestMetadata: AuthClientMetadata,
110 ) => Effect.Effect<
111 AuthAccessTokenResult,
112 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
113 >;
114 readonly createPairingLink: (input?: {
115 readonly ttl?: Duration.Duration;
116 readonly label?: string;
117 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
118 readonly subject?: string;
119 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
120 readonly issuePairingCredential: (
121 input?: AuthCreatePairingCredentialInput,
122 ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>;
123 readonly issueStartupPairingCredential: () => Effect.Effect<
124 AuthPairingCredentialResult,
125 ServerAuthInternalError
126 >;
127 readonly listPairingLinks: (input?: {
128 readonly excludeSubjects?: ReadonlyArray<string>;
129 }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>;
130 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>;
131 readonly issueSession: (input?: {
132 readonly ttl?: Duration.Duration;
133 readonly subject?: string;
134 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
135 readonly label?: string;
136 }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>;
137 readonly listSessions: () => Effect.Effect<
138 ReadonlyArray<AuthClientSession>,
139 ServerAuthInternalError
140 >;
141 readonly revokeSession: (
142 sessionId: AuthSessionId,
143 ) => Effect.Effect<boolean, ServerAuthInternalError>;
144 readonly revokeOtherSessionsExcept: (
145 sessionId: AuthSessionId,
146 ) => Effect.Effect<number, ServerAuthInternalError>;
147 readonly listClientSessions: (
148 currentSessionId: AuthSessionId,
149 ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>;
150 readonly revokeClientSession: (
151 currentSessionId: AuthSessionId,
152 targetSessionId: AuthSessionId,
153 ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>;
154 readonly revokeOtherClientSessions: (
155 currentSessionId: AuthSessionId,
156 ) => Effect.Effect<number, ServerAuthInternalError>;
157 readonly authenticateHttpRequest: (
158 request: HttpServerRequest.HttpServerRequest,
159 ) => Effect.Effect<
160 AuthenticatedSession,
161 ServerAuthInvalidCredentialError | ServerAuthInternalError
162 >;
163 readonly authenticateWebSocketUpgrade: (
164 request: HttpServerRequest.HttpServerRequest,
165 ) => Effect.Effect<
166 AuthenticatedSession,
167 ServerAuthInvalidCredentialError | ServerAuthInternalError
168 >;
169 readonly issueWebSocketTicket: (
170 session: Pick<AuthenticatedSession, "sessionId">,
171 ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>;
172 readonly issueStartupPairingUrl: (
173 baseUrl: string,
174 ) => Effect.Effect<string, ServerAuthInternalError>;
175}
176
177export class EnvironmentAuth extends Context.Service<EnvironmentAuth, EnvironmentAuthShape>()(
178 "t3/auth/EnvironmentAuth",
179) {}
180
181type BootstrapExchangeResult = {
182 readonly response: AuthBrowserSessionResult;
183 readonly sessionToken: string;
184};
185
186const AUTHORIZATION_PREFIX = "Bearer ";
187const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";
188
189const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
190 const leftCanManage = left.scopes.includes(AuthAccessWriteScope);
191 const rightCanManage = right.scopes.includes(AuthAccessWriteScope);
192 if (leftCanManage !== rightCanManage) {
193 return leftCanManage ? -1 : 1;
194 }
195 if (left.connected !== right.connected) {
196 return left.connected ? -1 : 1;
197 }
198 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
199};
200
201const toInternalError =
202 (message: string) =>
203 (cause: unknown): ServerAuthInternalError =>
204 new ServerAuthInternalError({ message, cause });
205
206export function toBootstrapExchangeError(
207 cause: PairingGrantStore.BootstrapCredentialError,
208): ServerAuthInvalidCredentialError | ServerAuthInternalError {
209 if (cause._tag === "BootstrapCredentialInternalError") {
210 return new ServerAuthInternalError({
211 message: "Failed to validate bootstrap credential.",
212 cause,
213 });
214 }
215
216 return new ServerAuthInvalidCredentialError({
217 reason: "invalid_credential",
218 cause,
219 });
220}
221
222const mapSessionVerificationErrors = <A, R>(
223 effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>,
224): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> =>
225 effect.pipe(
226 Effect.catchTags({
227 SessionCredentialInvalidError: (cause) =>
228 Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })),
229 SessionCredentialInternalError: (cause) =>
230 Effect.fail(
231 new ServerAuthInternalError({
232 message: "Failed to validate session credential.",
233 cause,
234 }),
235 ),
236 }),
237 );
238
239function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
240 const header = request.headers["authorization"];
241 if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) {
242 return null;
243 }
244 const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
245 return token.length > 0 ? token : null;
246}
247
248export const make = Effect.fn("makeEnvironmentAuth")(function* () {
249 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
250 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
251 const sessions = yield* SessionStore.SessionStore;
252 const descriptor = yield* policy.getDescriptor();
253
254 const authenticateToken = (
255 token: string,
256 ): Effect.Effect<
257 AuthenticatedSession,
258 ServerAuthInvalidCredentialError | ServerAuthInternalError
259 > =>
260 sessions.verify(token).pipe(
261 Effect.tapErrorTag("SessionCredentialInvalidError", (cause) =>
262 Effect.logWarning("Rejected authenticated session credential.").pipe(
263 Effect.annotateLogs({
264 reason: cause.message,
265 }),
266 ),
267 ),
268 Effect.map((session) => ({
269 sessionId: session.sessionId,
270 subject: session.subject,
271 method: session.method,
272 scopes: session.scopes,
273 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
274 })),
275 mapSessionVerificationErrors,
276 );
277
278 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
279 const cookieToken = request.cookies[sessions.cookieName];
280 const bearerToken = parseBearerToken(request);
281 const credential = cookieToken ?? bearerToken;
282 if (!credential) {
283 return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));
284 }
285 return authenticateToken(credential);
286 };
287
288 const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>
289 authenticateRequest(request).pipe(
290 Effect.map(
291 (session) =>
292 ({
293 authenticated: true,
294 auth: descriptor,
295 scopes: session.scopes,
296 sessionMethod: session.method,
297 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
298 }) satisfies AuthSessionState,
299 ),
300 Effect.catchTag("ServerAuthInvalidCredentialError", () =>
301 Effect.succeed({
302 authenticated: false,
303 auth: descriptor,
304 } satisfies AuthSessionState),
305 ),
306 Effect.withSpan("EnvironmentAuth.getSessionState"),
307 );
308
309 const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = (
310 credential,
311 requestMetadata,
312 ) =>
313 bootstrapCredentials.consume(credential).pipe(
314 Effect.mapError(toBootstrapExchangeError),
315 Effect.flatMap((grant) =>
316 sessions
317 .issue({
318 method: "browser-session-cookie",
319 subject: grant.subject,
320 scopes: grant.scopes,
321 client: {
322 ...requestMetadata,
323 ...(grant.label ? { label: grant.label } : {}),
324 },
325 })
326 .pipe(
327 Effect.mapError(
328 (cause) =>
329 new ServerAuthInternalError({
330 message: "Failed to issue authenticated session.",
331 cause,
332 }),
333 ),
334 ),
335 ),
336 Effect.map(
337 (session) =>
338 ({
339 response: {
340 authenticated: true,
341 scopes: session.scopes,
342 sessionMethod: session.method,
343 expiresAt: DateTime.toUtc(session.expiresAt),
344 } satisfies AuthBrowserSessionResult,
345 sessionToken: session.token,
346 }) satisfies BootstrapExchangeResult,
347 ),
348 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
349 );
350
351 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =
352 (credential, requestedScopes, requestMetadata) =>
353 bootstrapCredentials.consume(credential).pipe(
354 Effect.mapError(toBootstrapExchangeError),
355 Effect.flatMap((grant) =>
356 Effect.gen(function* () {
357 const grantedScopes = requestedScopes ?? grant.scopes;
358 if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) {
359 return yield* new ServerAuthInvalidRequestError({
360 reason: "scope_not_granted",
361 });
362 }
363 return yield* sessions
364 .issue({
365 method: "bearer-access-token",
366 subject: grant.subject,
367 scopes: grantedScopes,
368 client: {
369 ...requestMetadata,
370 ...(grant.label ? { label: grant.label } : {}),
371 },
372 })
373 .pipe(
374 Effect.mapError(
375 (cause) =>
376 new ServerAuthInternalError({
377 message: "Failed to issue authenticated access token.",
378 cause,
379 }),
380 ),
381 );
382 }),
383 ),
384 Effect.flatMap((session) =>
385 DateTime.now.pipe(
386 Effect.map(
387 (now) =>
388 ({
389 access_token: session.token,
390 issued_token_type: AuthAccessTokenType,
391 token_type: "Bearer",
392 expires_in: Math.max(
393 0,
394 Math.floor(
395 (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000,
396 ),
397 ),
398 scope: encodeOAuthScope(session.scopes),
399 }) satisfies AuthAccessTokenResult,
400 ),
401 ),
402 ),
403 Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"),
404 );
405
406 const issuePairingCredentialForSubject = (input: {
407 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
408 readonly subject: string;
409 readonly label?: string;
410 }) =>
411 createPairingLink({
412 scopes: input.scopes,
413 subject: input.subject,
414 ...(input.label ? { label: input.label } : {}),
415 }).pipe(
416 Effect.map(
417 (issued) =>
418 ({
419 id: issued.id,
420 credential: issued.credential,
421 ...(issued.label ? { label: issued.label } : {}),
422 expiresAt: issued.expiresAt,
423 }) satisfies AuthPairingCredentialResult,
424 ),
425 );
426
427 const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn(
428 "EnvironmentAuth.createPairingLink",
429 )(
430 function* (input) {
431 const createdAt = yield* DateTime.now;
432 const issued = yield* bootstrapCredentials.issueOneTimeToken({
433 scopes: input?.scopes ?? AuthStandardClientScopes,
434 subject: input?.subject ?? "one-time-token",
435 ...(input?.ttl ? { ttl: input.ttl } : {}),
436 ...(input?.label ? { label: input.label } : {}),
437 });
438 return {
439 id: issued.id,
440 credential: issued.credential,
441 scopes: input?.scopes ?? AuthStandardClientScopes,
442 subject: input?.subject ?? "one-time-token",
443 ...(issued.label ? { label: issued.label } : {}),
444 createdAt: DateTime.toUtc(createdAt),
445 expiresAt: DateTime.toUtc(issued.expiresAt),
446 } satisfies IssuedPairingLink;
447 },
448 Effect.mapError(toInternalError("Failed to create pairing link.")),
449 );
450
451 const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) =>
452 bootstrapCredentials.listActive().pipe(
453 Effect.map((pairingLinks) => {
454 const excludedSubjects = input?.excludeSubjects ?? [
455 INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
456 ];
457 return pairingLinks
458 .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject))
459 .toSorted(
460 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
461 );
462 }),
463 Effect.mapError(toInternalError("Failed to list pairing links.")),
464 Effect.withSpan("EnvironmentAuth.listPairingLinks"),
465 );
466
467 const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) =>
468 bootstrapCredentials
469 .revoke(id)
470 .pipe(
471 Effect.mapError(toInternalError("Failed to revoke pairing link.")),
472 Effect.withSpan("EnvironmentAuth.revokePairingLink"),
473 );
474
475 const issueSession: EnvironmentAuthShape["issueSession"] = (input) =>
476 sessions
477 .issue({
478 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
479 method: "bearer-access-token",
480 scopes: input?.scopes ?? AuthAdministrativeScopes,
481 client: {
482 ...(input?.label ? { label: input.label } : {}),
483 deviceType: "bot",
484 },
485 ...(input?.ttl ? { ttl: input.ttl } : {}),
486 })
487 .pipe(
488 Effect.map(
489 (issued) =>
490 ({
491 sessionId: issued.sessionId,
492 token: issued.token,
493 method: "bearer-access-token",
494 scopes: issued.scopes,
495 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
496 client: issued.client,
497 expiresAt: DateTime.toUtc(issued.expiresAt),
498 }) satisfies IssuedBearerSession,
499 ),
500 Effect.mapError(toInternalError("Failed to issue session token.")),
501 Effect.withSpan("EnvironmentAuth.issueSession"),
502 );
503
504 const listSessions: EnvironmentAuthShape["listSessions"] = () =>
505 sessions.listActive().pipe(
506 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
507 Effect.mapError(toInternalError("Failed to list sessions.")),
508 Effect.withSpan("EnvironmentAuth.listSessions"),
509 );
510
511 const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) =>
512 sessions
513 .revoke(sessionId)
514 .pipe(
515 Effect.mapError(toInternalError("Failed to revoke session.")),
516 Effect.withSpan("EnvironmentAuth.revokeSession"),
517 );
518
519 const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = (
520 sessionId,
521 ) =>
522 sessions
523 .revokeAllExcept(sessionId)
524 .pipe(
525 Effect.mapError(toInternalError("Failed to revoke other sessions.")),
526 Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"),
527 );
528
529 const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) =>
530 issuePairingCredentialForSubject({
531 scopes: input?.scopes ?? AuthStandardClientScopes,
532 subject: "one-time-token",
533 ...(input?.label ? { label: input.label } : {}),
534 }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential"));
535
536 const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () =>
537 issuePairingCredentialForSubject({
538 scopes: AuthAdministrativeScopes,
539 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
540 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
541
542 const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) =>
543 listSessions().pipe(
544 Effect.map((clientSessions) =>
545 clientSessions.map(
546 (clientSession): AuthClientSession => ({
547 ...clientSession,
548 current: clientSession.sessionId === currentSessionId,
549 }),
550 ),
551 ),
552 Effect.withSpan("EnvironmentAuth.listClientSessions"),
553 );
554
555 const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn(
556 "EnvironmentAuth.revokeClientSession",
557 )(function* (currentSessionId, targetSessionId) {
558 if (currentSessionId === targetSessionId) {
559 return yield* new ServerAuthForbiddenOperationError({
560 reason: "current_session_revoke_not_allowed",
561 });
562 }
563 return yield* revokeSession(targetSessionId);
564 });
565
566 const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = (
567 currentSessionId,
568 ) =>
569 revokeOtherSessionsExcept(currentSessionId).pipe(
570 Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"),
571 );
572
573 const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
574 issueStartupPairingCredential().pipe(
575 Effect.map((issued) => {
576 const url = new URL(baseUrl);
577 url.pathname = "/pair";
578 url.searchParams.delete("token");
579 url.hash = new URLSearchParams([["token", issued.credential]]).toString();
580 return url.toString();
581 }),
582 Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"),
583 );
584
585 const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) =>
586 sessions.issueWebSocketToken(session.sessionId).pipe(
587 Effect.mapError(
588 (cause) =>
589 new ServerAuthInternalError({
590 message: "Failed to issue websocket token.",
591 cause,
592 }),
593 ),
594 Effect.map(
595 (issued) =>
596 ({
597 ticket: issued.token,
598 expiresAt: DateTime.toUtc(issued.expiresAt),
599 }) satisfies AuthWebSocketTicketResult,
600 ),
601 Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"),
602 );
603
604 const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) =>
605 authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest"));
606
607 const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] =
608 Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) {
609 const requestUrl = HttpServerRequest.toURL(request);
610 if (Option.isSome(requestUrl)) {
611 const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM);
612 if (websocketTicket && websocketTicket.trim().length > 0) {
613 return yield* sessions.verifyWebSocketToken(websocketTicket).pipe(
614 Effect.map((session) => ({
615 sessionId: session.sessionId,
616 subject: session.subject,
617 method: session.method,
618 scopes: session.scopes,
619 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
620 })),
621 mapSessionVerificationErrors,
622 );
623 }
624 }
625
626 return yield* authenticateRequest(request);
627 });
628
629 return {
630 getDescriptor: () =>
631 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")),
632 getSessionState,
633 createBrowserSession,
634 exchangeBootstrapCredentialForAccessToken,
635 createPairingLink,
636 issuePairingCredential,
637 issueStartupPairingCredential,
638 listPairingLinks,
639 revokePairingLink,
640 issueSession,
641 listSessions,
642 revokeSession,
643 revokeOtherSessionsExcept,
644 listClientSessions,
645 revokeClientSession,
646 revokeOtherClientSessions,
647 authenticateHttpRequest,
648 authenticateWebSocketUpgrade,
649 issueWebSocketTicket,
650 issueStartupPairingUrl,
651 } satisfies EnvironmentAuthShape;
652});
653
654export const layer = Layer.effect(EnvironmentAuth, make()).pipe(
655 Layer.provideMerge(PairingGrantStore.layer),
656 Layer.provideMerge(SessionStore.layer),
657 Layer.provideMerge(EnvironmentAuthPolicy.layer),
658);
659
660export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer);
661
662export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer));
apps/server/src/auth/EnvironmentAuthAdmin.test.ts created+125
......@@ -0,0 +1,125 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../config.ts";
7import { ServerConfig } from "../config.ts";
8import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
9import * as EnvironmentAuth from "./EnvironmentAuth.ts";
10import * as ServerSecretStore from "./ServerSecretStore.ts";
11import * as SessionStore from "./SessionStore.ts";
12
13const makeServerConfigLayer = (
14 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
15) =>
16 Layer.effect(
17 ServerConfig,
18 Effect.gen(function* () {
19 const config = yield* ServerConfig;
20 return {
21 ...config,
22 ...overrides,
23 } satisfies ServerConfigShape;
24 }),
25 ).pipe(
26 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })),
27 );
28
29const makeEnvironmentAuthLayer = (
30 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
31) =>
32 EnvironmentAuth.layer.pipe(
33 Layer.provideMerge(ServerSecretStore.layer),
34 Layer.provideMerge(SqlitePersistenceMemory),
35 Layer.provide(makeServerConfigLayer(overrides)),
36 );
37
38it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) => {
39 it.effect("creates, lists, and revokes client pairing links", () =>
40 Effect.gen(function* () {
41 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
42
43 const created = yield* environmentAuth.createPairingLink({
44 scopes: ["orchestration:read"],
45 subject: "one-time-token",
46 label: "CI phone",
47 });
48 const listedBeforeRevoke = yield* environmentAuth.listPairingLinks();
49 const revoked = yield* environmentAuth.revokePairingLink(created.id);
50 const listedAfterRevoke = yield* environmentAuth.listPairingLinks();
51
52 expect(created.scopes).toEqual(["orchestration:read"]);
53 expect(created.credential.length).toBeGreaterThan(0);
54 expect(listedBeforeRevoke).toHaveLength(1);
55 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
56 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
57 expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);
58 expect(revoked).toBe(true);
59 expect(listedAfterRevoke).toHaveLength(0);
60 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
61 );
62
63 it.effect("issues bearer access token sessions without exposing raw tokens", () =>
64 Effect.gen(function* () {
65 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
66 const sessionCredentials = yield* SessionStore.SessionStore;
67
68 const issued = yield* environmentAuth.issueSession({
69 label: "deploy-bot",
70 });
71 const verified = yield* sessionCredentials.verify(issued.token);
72 const listedBeforeRevoke = yield* environmentAuth.listSessions();
73 const revoked = yield* environmentAuth.revokeSession(issued.sessionId);
74 const listedAfterRevoke = yield* environmentAuth.listSessions();
75
76 expect(issued.method).toBe("bearer-access-token");
77 expect(issued.scopes).toEqual([
78 "orchestration:read",
79 "orchestration:operate",
80 "terminal:operate",
81 "review:write",
82 "relay:read",
83 "access:read",
84 "access:write",
85 "relay:write",
86 ]);
87 expect(issued.client.deviceType).toBe("bot");
88 expect(issued.client.label).toBe("deploy-bot");
89 expect(verified.sessionId).toBe(issued.sessionId);
90 expect(verified.scopes).toEqual([
91 "orchestration:read",
92 "orchestration:operate",
93 "terminal:operate",
94 "review:write",
95 "relay:read",
96 "access:read",
97 "access:write",
98 "relay:write",
99 ]);
100 expect(verified.method).toBe("bearer-access-token");
101 expect(listedBeforeRevoke).toHaveLength(1);
102 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
103 expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false);
104 expect(revoked).toBe(true);
105 expect(listedAfterRevoke).toHaveLength(0);
106 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
107 );
108
109 it.effect("surfaces lastConnectedAt through the listed session view", () =>
110 Effect.gen(function* () {
111 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
112 const sessionCredentials = yield* SessionStore.SessionStore;
113
114 const issued = yield* environmentAuth.issueSession({
115 label: "remote-ipad",
116 });
117 const beforeConnect = yield* environmentAuth.listSessions();
118 yield* sessionCredentials.markConnected(issued.sessionId);
119 const afterConnect = yield* environmentAuth.listSessions();
120
121 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
122 expect(afterConnect[0]?.lastConnectedAt).not.toBeNull();
123 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
124 );
125});
apps/server/src/auth/EnvironmentAuthPolicy.test.ts created+114
......@@ -0,0 +1,114 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../config.ts";
7import { ServerConfig } from "../config.ts";
8import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
9
10const makeEnvironmentAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) =>
11 EnvironmentAuthPolicy.layer.pipe(
12 Layer.provide(
13 Layer.effect(
14 ServerConfig,
15 Effect.gen(function* () {
16 const config = yield* ServerConfig;
17 return {
18 ...config,
19 ...overrides,
20 } satisfies ServerConfigShape;
21 }),
22 ).pipe(
23 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })),
24 ),
25 ),
26 );
27
28it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
29 it.effect("uses desktop-managed-local policy for desktop mode", () =>
30 Effect.gen(function* () {
31 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
32 const descriptor = yield* policy.getDescriptor();
33
34 expect(descriptor.policy).toBe("desktop-managed-local");
35 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
36 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
37 }).pipe(
38 Effect.provide(
39 makeEnvironmentAuthPolicyLayer({
40 mode: "desktop",
41 port: 3773,
42 }),
43 ),
44 ),
45 );
46
47 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
48 Effect.gen(function* () {
49 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
50 const descriptor = yield* policy.getDescriptor();
51
52 expect(descriptor.policy).toBe("remote-reachable");
53 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]);
54 }).pipe(
55 Effect.provide(
56 makeEnvironmentAuthPolicyLayer({
57 mode: "desktop",
58 host: "0.0.0.0",
59 }),
60 ),
61 ),
62 );
63
64 it.effect("uses loopback-browser policy for loopback web hosts", () =>
65 Effect.gen(function* () {
66 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
67 const descriptor = yield* policy.getDescriptor();
68
69 expect(descriptor.policy).toBe("loopback-browser");
70 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
71 expect(descriptor.sessionCookieName).toBe("t3_session");
72 }).pipe(
73 Effect.provide(
74 makeEnvironmentAuthPolicyLayer({
75 mode: "web",
76 host: "127.0.0.1",
77 }),
78 ),
79 ),
80 );
81
82 it.effect("uses remote-reachable policy for wildcard web hosts", () =>
83 Effect.gen(function* () {
84 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
85 const descriptor = yield* policy.getDescriptor();
86
87 expect(descriptor.policy).toBe("remote-reachable");
88 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
89 }).pipe(
90 Effect.provide(
91 makeEnvironmentAuthPolicyLayer({
92 mode: "web",
93 host: "0.0.0.0",
94 }),
95 ),
96 ),
97 );
98
99 it.effect("uses remote-reachable policy for non-loopback web hosts", () =>
100 Effect.gen(function* () {
101 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
102 const descriptor = yield* policy.getDescriptor();
103
104 expect(descriptor.policy).toBe("remote-reachable");
105 }).pipe(
106 Effect.provide(
107 makeEnvironmentAuthPolicyLayer({
108 mode: "web",
109 host: "192.168.1.50",
110 }),
111 ),
112 ),
113 );
114});
apps/server/src/auth/EnvironmentAuthPolicy.ts created+55
......@@ -0,0 +1,55 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { ServerConfig } from "../config.ts";
7import { resolveSessionCookieName } from "./utils.ts";
8import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
9
10export interface EnvironmentAuthPolicyShape {
11 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
12}
13
14export class EnvironmentAuthPolicy extends Context.Service<
15 EnvironmentAuthPolicy,
16 EnvironmentAuthPolicyShape
17>()("t3/auth/EnvironmentAuthPolicy") {}
18
19export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () {
20 const config = yield* ServerConfig;
21 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
22
23 const policy =
24 config.mode === "desktop"
25 ? isRemoteReachable
26 ? "remote-reachable"
27 : "desktop-managed-local"
28 : isRemoteReachable
29 ? "remote-reachable"
30 : "loopback-browser";
31
32 const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] =
33 policy === "desktop-managed-local"
34 ? ["desktop-bootstrap"]
35 : config.mode === "desktop" && policy === "remote-reachable"
36 ? ["desktop-bootstrap", "one-time-token"]
37 : ["one-time-token"];
38
39 const descriptor: ServerAuthDescriptor = {
40 policy,
41 bootstrapMethods,
42 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
43 sessionCookieName: resolveSessionCookieName({
44 mode: config.mode,
45 port: config.port,
46 }),
47 };
48
49 return {
50 getDescriptor: () =>
51 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")),
52 } satisfies EnvironmentAuthPolicyShape;
53});
54
55export const layer = Layer.effect(EnvironmentAuthPolicy, make());
apps/server/src/auth/Layers/AuthControlPlane.test.ts deleted-112
......@@ -1,112 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
9import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
10import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
11import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
12import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
13import { makeAuthControlPlane } from "./AuthControlPlane.ts";
14import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
15
16const makeServerConfigLayer = (
17 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
18) =>
19 Layer.effect(
20 ServerConfig,
21 Effect.gen(function* () {
22 const config = yield* ServerConfig;
23 return {
24 ...config,
25 ...overrides,
26 } satisfies ServerConfigShape;
27 }),
28 ).pipe(
29 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })),
30 );
31
32const makeAuthControlPlaneLayer = (
33 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
34) =>
35 Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe(
36 Layer.provideMerge(BootstrapCredentialServiceLive),
37 Layer.provideMerge(SessionCredentialServiceLive),
38 Layer.provideMerge(ServerSecretStoreLive),
39 Layer.provideMerge(SqlitePersistenceMemory),
40 Layer.provide(makeServerConfigLayer(overrides)),
41 );
42
43it.layer(NodeServices.layer)("AuthControlPlane", (it) => {
44 it.effect("creates, lists, and revokes client pairing links", () =>
45 Effect.gen(function* () {
46 const authControlPlane = yield* AuthControlPlane;
47
48 const created = yield* authControlPlane.createPairingLink({
49 role: "client",
50 subject: "one-time-token",
51 label: "CI phone",
52 });
53 const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
54 const revoked = yield* authControlPlane.revokePairingLink(created.id);
55 const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
56
57 expect(created.role).toBe("client");
58 expect(created.credential.length).toBeGreaterThan(0);
59 expect(listedBeforeRevoke).toHaveLength(1);
60 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
61 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
62 expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);
63 expect(revoked).toBe(true);
64 expect(listedAfterRevoke).toHaveLength(0);
65 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
66 );
67
68 it.effect("issues bearer sessions and lists them without exposing raw tokens", () =>
69 Effect.gen(function* () {
70 const authControlPlane = yield* AuthControlPlane;
71 const sessionCredentials = yield* SessionCredentialService;
72
73 const issued = yield* authControlPlane.issueSession({
74 label: "deploy-bot",
75 });
76 const verified = yield* sessionCredentials.verify(issued.token);
77 const listedBeforeRevoke = yield* authControlPlane.listSessions();
78 const revoked = yield* authControlPlane.revokeSession(issued.sessionId);
79 const listedAfterRevoke = yield* authControlPlane.listSessions();
80
81 expect(issued.method).toBe("bearer-session-token");
82 expect(issued.role).toBe("owner");
83 expect(issued.client.deviceType).toBe("bot");
84 expect(issued.client.label).toBe("deploy-bot");
85 expect(verified.sessionId).toBe(issued.sessionId);
86 expect(verified.role).toBe("owner");
87 expect(verified.method).toBe("bearer-session-token");
88 expect(listedBeforeRevoke).toHaveLength(1);
89 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
90 expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false);
91 expect(revoked).toBe(true);
92 expect(listedAfterRevoke).toHaveLength(0);
93 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
94 );
95
96 it.effect("surfaces lastConnectedAt through the listed session view", () =>
97 Effect.gen(function* () {
98 const authControlPlane = yield* AuthControlPlane;
99 const sessionCredentials = yield* SessionCredentialService;
100
101 const issued = yield* authControlPlane.issueSession({
102 label: "remote-ipad",
103 });
104 const beforeConnect = yield* authControlPlane.listSessions();
105 yield* sessionCredentials.markConnected(issued.sessionId);
106 const afterConnect = yield* authControlPlane.listSessions();
107
108 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
109 expect(afterConnect[0]?.lastConnectedAt).not.toBeNull();
110 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
111 );
112});
apps/server/src/auth/Layers/AuthControlPlane.ts deleted-186
......@@ -1,186 +0,0 @@
1import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
7import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
8import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
9import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
10import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
11import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts";
12import {
13 AuthControlPlane,
14 AuthControlPlaneError,
15 DEFAULT_SESSION_SUBJECT,
16} from "../Services/AuthControlPlane.ts";
17import type {
18 AuthControlPlaneShape,
19 IssuedBearerSession,
20 IssuedPairingLink,
21} from "../Services/AuthControlPlane.ts";
22
23const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
24 if (left.role !== right.role) {
25 return left.role === "owner" ? -1 : 1;
26 }
27 if (left.connected !== right.connected) {
28 return left.connected ? -1 : 1;
29 }
30 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
31};
32
33const toAuthControlPlaneError =
34 (message: string) =>
35 (cause: unknown): AuthControlPlaneError =>
36 new AuthControlPlaneError({
37 message,
38 cause,
39 });
40
41export const makeAuthControlPlane = Effect.gen(function* () {
42 const bootstrapCredentials = yield* BootstrapCredentialService;
43 const sessions = yield* SessionCredentialService;
44
45 const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) =>
46 Effect.gen(function* () {
47 const createdAt = yield* DateTime.now;
48 const issued = yield* bootstrapCredentials.issueOneTimeToken({
49 role: input?.role ?? "client",
50 subject: input?.subject ?? "one-time-token",
51 ...(input?.ttl ? { ttl: input.ttl } : {}),
52 ...(input?.label ? { label: input.label } : {}),
53 });
54 return {
55 id: issued.id,
56 credential: issued.credential,
57 role: input?.role ?? "client",
58 subject: input?.subject ?? "one-time-token",
59 ...(issued.label ? { label: issued.label } : {}),
60 createdAt: DateTime.toUtc(createdAt),
61 expiresAt: DateTime.toUtc(issued.expiresAt),
62 } satisfies IssuedPairingLink;
63 }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link.")));
64
65 const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) =>
66 bootstrapCredentials.listActive().pipe(
67 Effect.map((pairingLinks) => {
68 const activeLinks: Array<AuthPairingLink> = [];
69 for (const pairingLink of pairingLinks) {
70 if (input?.role && pairingLink.role !== input.role) {
71 continue;
72 }
73 if (input?.excludeSubjects?.includes(pairingLink.subject)) {
74 continue;
75 }
76 activeLinks.push(
77 pairingLink.label
78 ? ({
79 id: pairingLink.id,
80 credential: pairingLink.credential,
81 role: pairingLink.role,
82 subject: pairingLink.subject,
83 label: pairingLink.label,
84 createdAt: pairingLink.createdAt,
85 expiresAt: pairingLink.expiresAt,
86 } satisfies AuthPairingLink)
87 : ({
88 id: pairingLink.id,
89 credential: pairingLink.credential,
90 role: pairingLink.role,
91 subject: pairingLink.subject,
92 createdAt: pairingLink.createdAt,
93 expiresAt: pairingLink.expiresAt,
94 } satisfies AuthPairingLink),
95 );
96 }
97 return activeLinks.toSorted(
98 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
99 );
100 }),
101 Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")),
102 );
103
104 const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) =>
105 bootstrapCredentials
106 .revoke(id)
107 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link.")));
108
109 const issueSession: AuthControlPlaneShape["issueSession"] = (input) =>
110 sessions
111 .issue({
112 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
113 method: "bearer-session-token",
114 role: input?.role ?? "owner",
115 client: {
116 ...(input?.label ? { label: input.label } : {}),
117 deviceType: "bot",
118 },
119 ...(input?.ttl ? { ttl: input.ttl } : {}),
120 })
121 .pipe(
122 Effect.flatMap((issued) => {
123 if (issued.method !== "bearer-session-token") {
124 return Effect.fail(
125 new AuthControlPlaneError({
126 message: "CLI session issuance produced an unexpected session method.",
127 }),
128 );
129 }
130
131 return Effect.succeed({
132 sessionId: issued.sessionId,
133 token: issued.token,
134 method: "bearer-session-token" as const,
135 role: issued.role,
136 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
137 client: issued.client,
138 expiresAt: DateTime.toUtc(issued.expiresAt),
139 } satisfies IssuedBearerSession);
140 }),
141 Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")),
142 );
143
144 const listSessions: AuthControlPlaneShape["listSessions"] = () =>
145 sessions.listActive().pipe(
146 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
147 Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")),
148 );
149
150 const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) =>
151 sessions
152 .revoke(sessionId)
153 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session.")));
154
155 const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = (
156 sessionId,
157 ) =>
158 sessions
159 .revokeAllExcept(sessionId)
160 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions.")));
161
162 return {
163 createPairingLink,
164 listPairingLinks,
165 revokePairingLink,
166 issueSession,
167 listSessions,
168 revokeSession,
169 revokeOtherSessionsExcept,
170 } satisfies AuthControlPlaneShape;
171});
172
173export const AuthCoreLive = Layer.mergeAll(
174 BootstrapCredentialServiceLive,
175 SessionCredentialServiceLive,
176);
177
178export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive);
179
180export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive));
181
182export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane);
183
184export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe(
185 Layer.provideMerge(AuthRuntimeLive),
186);
apps/server/src/auth/Layers/BootstrapCredentialService.test.ts deleted-153
......@@ -1,153 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../../config.ts";
9import { ServerConfig } from "../../config.ts";
10import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
12import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
13
14const makeServerConfigLayer = (
15 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
16) =>
17 Layer.effect(
18 ServerConfig,
19 Effect.gen(function* () {
20 const config = yield* ServerConfig;
21 return {
22 ...config,
23 ...overrides,
24 } satisfies ServerConfigShape;
25 }),
26 ).pipe(
27 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })),
28 );
29
30const makeBootstrapCredentialLayer = (
31 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
32) =>
33 BootstrapCredentialServiceLive.pipe(
34 Layer.provide(SqlitePersistenceMemory),
35 Layer.provide(makeServerConfigLayer(overrides)),
36 );
37
38it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => {
39 it.effect("issues pairing tokens in a short manual-entry format", () =>
40 Effect.gen(function* () {
41 const bootstrapCredentials = yield* BootstrapCredentialService;
42 const issued = yield* bootstrapCredentials.issueOneTimeToken();
43
44 expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/);
45 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
46 );
47
48 it.effect("issues one-time bootstrap tokens that can only be consumed once", () =>
49 Effect.gen(function* () {
50 const bootstrapCredentials = yield* BootstrapCredentialService;
51 const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" });
52 const first = yield* bootstrapCredentials.consume(issued.credential);
53 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
54
55 expect(first.method).toBe("one-time-token");
56 expect(first.role).toBe("client");
57 expect(first.subject).toBe("one-time-token");
58 expect(first.label).toBe("Julius iPhone");
59 expect(issued.label).toBe("Julius iPhone");
60 expect(second._tag).toBe("BootstrapCredentialError");
61 expect(second.message).toContain("Unknown bootstrap credential");
62 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
63 );
64
65 it.effect("atomically consumes a one-time token when multiple requests race", () =>
66 Effect.gen(function* () {
67 const bootstrapCredentials = yield* BootstrapCredentialService;
68 const token = yield* bootstrapCredentials.issueOneTimeToken();
69 const results = yield* Effect.all(
70 Array.from({ length: 8 }, () =>
71 Effect.result(bootstrapCredentials.consume(token.credential)),
72 ),
73 {
74 concurrency: "unbounded",
75 },
76 );
77
78 const successes = results.filter((result) => result._tag === "Success");
79 const failures = results.filter((result) => result._tag === "Failure");
80
81 expect(successes).toHaveLength(1);
82 expect(failures).toHaveLength(7);
83 for (const failure of failures) {
84 expect(failure.failure._tag).toBe("BootstrapCredentialError");
85 expect(failure.failure.message).toContain("Unknown bootstrap credential");
86 }
87 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
88 );
89
90 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
91 Effect.gen(function* () {
92 const bootstrapCredentials = yield* BootstrapCredentialService;
93 const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
94 const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
95
96 expect(first.method).toBe("desktop-bootstrap");
97 expect(first.role).toBe("owner");
98 expect(first.subject).toBe("desktop-bootstrap");
99 expect(second._tag).toBe("BootstrapCredentialError");
100 expect(second.status).toBe(401);
101 }).pipe(
102 Effect.provide(
103 makeBootstrapCredentialLayer({
104 desktopBootstrapToken: "desktop-bootstrap-token",
105 }),
106 ),
107 ),
108 );
109
110 it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
111 Effect.gen(function* () {
112 const bootstrapCredentials = yield* BootstrapCredentialService;
113
114 yield* TestClock.adjust(Duration.minutes(6));
115 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
116
117 expect(expired._tag).toBe("BootstrapCredentialError");
118 expect(expired.status).toBe(401);
119 expect(expired.message).toContain("Bootstrap credential expired");
120 }).pipe(
121 Effect.provide(
122 Layer.merge(
123 makeBootstrapCredentialLayer({
124 desktopBootstrapToken: "desktop-bootstrap-token",
125 }),
126 TestClock.layer(),
127 ),
128 ),
129 ),
130 );
131
132 it.effect("lists and revokes active pairing links", () =>
133 Effect.gen(function* () {
134 const bootstrapCredentials = yield* BootstrapCredentialService;
135 const first = yield* bootstrapCredentials.issueOneTimeToken();
136 const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" });
137
138 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
139 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
140 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
141
142 const revoked = yield* bootstrapCredentials.revoke(first.id);
143 const activeAfterRevoke = yield* bootstrapCredentials.listActive();
144 const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential));
145
146 expect(revoked).toBe(true);
147 expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
148 expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
149 expect(revokedConsume.message).toContain("no longer available");
150 expect(revokedConsume.status).toBe(401);
151 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
152 );
153});
apps/server/src/auth/Layers/BootstrapCredentialService.ts deleted-304
......@@ -1,304 +0,0 @@
1import type { AuthPairingLink } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as PubSub from "effect/PubSub";
8import * as Ref from "effect/Ref";
9import * as Stream from "effect/Stream";
10import * as Option from "effect/Option";
11
12import { ServerConfig } from "../../config.ts";
13import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts";
14import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts";
15import {
16 BootstrapCredentialError,
17 BootstrapCredentialService,
18 type BootstrapCredentialChange,
19 type BootstrapCredentialServiceShape,
20 type BootstrapGrant,
21 type IssuedBootstrapCredential,
22} from "../Services/BootstrapCredentialService.ts";
23
24interface StoredBootstrapGrant extends BootstrapGrant {
25 readonly remainingUses: number | "unbounded";
26}
27
28type ConsumeResult =
29 | {
30 readonly _tag: "error";
31 readonly reason: "not-found" | "expired";
32 readonly error: BootstrapCredentialError;
33 }
34 | {
35 readonly _tag: "success";
36 readonly grant: BootstrapGrant;
37 };
38
39const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
40const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
41const PAIRING_TOKEN_LENGTH = 12;
42
43const generatePairingToken = (): string => {
44 const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH));
45
46 return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join("");
47};
48
49export const makeBootstrapCredentialService = Effect.gen(function* () {
50 const crypto = yield* Crypto.Crypto;
51 const config = yield* ServerConfig;
52 const pairingLinks = yield* AuthPairingLinkRepository;
53 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
54 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
55
56 const invalidBootstrapCredentialError = (message: string) =>
57 new BootstrapCredentialError({
58 message,
59 status: 401,
60 });
61
62 const internalBootstrapCredentialError = (message: string, cause: unknown) =>
63 new BootstrapCredentialError({
64 message,
65 status: 500,
66 cause,
67 });
68
69 const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
70 Ref.update(seededGrantsRef, (current) => {
71 const next = new Map(current);
72 next.set(credential, grant);
73 return next;
74 });
75
76 const emitUpsert = (pairingLink: AuthPairingLink) =>
77 PubSub.publish(changesPubSub, {
78 type: "pairingLinkUpserted",
79 pairingLink,
80 }).pipe(Effect.asVoid);
81
82 const emitRemoved = (id: string) =>
83 PubSub.publish(changesPubSub, {
84 type: "pairingLinkRemoved",
85 id,
86 }).pipe(Effect.asVoid);
87
88 if (config.desktopBootstrapToken) {
89 const now = yield* DateTime.now;
90 yield* seedGrant(config.desktopBootstrapToken, {
91 method: "desktop-bootstrap",
92 role: "owner",
93 subject: "desktop-bootstrap",
94 expiresAt: DateTime.add(now, {
95 milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES),
96 }),
97 remainingUses: 1,
98 });
99 }
100
101 const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
102 internalBootstrapCredentialError(message, cause);
103
104 const listActive: BootstrapCredentialServiceShape["listActive"] = () =>
105 Effect.gen(function* () {
106 const now = yield* DateTime.now;
107 const rows = yield* pairingLinks.listActive({ now });
108
109 return rows.map((row) =>
110 row.label
111 ? ({
112 id: row.id,
113 credential: row.credential,
114 role: row.role,
115 subject: row.subject,
116 label: row.label,
117 createdAt: row.createdAt,
118 expiresAt: row.expiresAt,
119 } satisfies AuthPairingLink)
120 : ({
121 id: row.id,
122 credential: row.credential,
123 role: row.role,
124 subject: row.subject,
125 createdAt: row.createdAt,
126 expiresAt: row.expiresAt,
127 } satisfies AuthPairingLink),
128 );
129 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")));
130
131 const revoke: BootstrapCredentialServiceShape["revoke"] = (id) =>
132 Effect.gen(function* () {
133 const revokedAt = yield* DateTime.now;
134 const revoked = yield* pairingLinks.revoke({
135 id,
136 revokedAt,
137 });
138 if (revoked) {
139 yield* emitRemoved(id);
140 }
141 return revoked;
142 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")));
143
144 const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) =>
145 Effect.gen(function* () {
146 const id = yield* crypto.randomUUIDv4;
147 const credential = generatePairingToken();
148 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
149 const now = yield* DateTime.now;
150 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
151 const issued: IssuedBootstrapCredential = {
152 id,
153 credential,
154 ...(input?.label ? { label: input.label } : {}),
155 expiresAt,
156 };
157 yield* pairingLinks.create({
158 id,
159 credential,
160 method: "one-time-token",
161 role: input?.role ?? "client",
162 subject: input?.subject ?? "one-time-token",
163 label: input?.label ?? null,
164 createdAt: now,
165 expiresAt: expiresAt,
166 });
167 yield* emitUpsert({
168 id,
169 credential,
170 role: input?.role ?? "client",
171 subject: input?.subject ?? "one-time-token",
172 ...(input?.label ? { label: input.label } : {}),
173 createdAt: now,
174 expiresAt,
175 });
176 return issued;
177 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")));
178
179 const consume: BootstrapCredentialServiceShape["consume"] = (credential) =>
180 Effect.gen(function* () {
181 const now = yield* DateTime.now;
182 const seededResult: ConsumeResult = yield* Ref.modify(
183 seededGrantsRef,
184 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
185 const grant = current.get(credential);
186 if (!grant) {
187 return [
188 {
189 _tag: "error",
190 reason: "not-found",
191 error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
192 },
193 current,
194 ];
195 }
196
197 const next = new Map(current);
198 if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
199 next.delete(credential);
200 return [
201 {
202 _tag: "error",
203 reason: "expired",
204 error: invalidBootstrapCredentialError("Bootstrap credential expired."),
205 },
206 next,
207 ];
208 }
209
210 const remainingUses = grant.remainingUses;
211 if (typeof remainingUses === "number") {
212 if (remainingUses <= 1) {
213 next.delete(credential);
214 } else {
215 next.set(credential, {
216 ...grant,
217 remainingUses: remainingUses - 1,
218 });
219 }
220 }
221
222 return [
223 {
224 _tag: "success",
225 grant: {
226 method: grant.method,
227 role: grant.role,
228 subject: grant.subject,
229 ...(grant.label ? { label: grant.label } : {}),
230 expiresAt: grant.expiresAt,
231 } satisfies BootstrapGrant,
232 },
233 next,
234 ];
235 },
236 );
237
238 if (seededResult._tag === "success") {
239 return seededResult.grant;
240 }
241 if (seededResult.reason !== "not-found") {
242 return yield* seededResult.error;
243 }
244
245 const consumed = yield* pairingLinks.consumeAvailable({
246 credential,
247 consumedAt: now,
248 now,
249 });
250
251 if (Option.isSome(consumed)) {
252 yield* emitRemoved(consumed.value.id);
253 return {
254 method: consumed.value.method,
255 role: consumed.value.role,
256 subject: consumed.value.subject,
257 ...(consumed.value.label ? { label: consumed.value.label } : {}),
258 expiresAt: consumed.value.expiresAt,
259 } satisfies BootstrapGrant;
260 }
261
262 const matching = yield* pairingLinks.getByCredential({ credential });
263 if (Option.isNone(matching)) {
264 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
265 }
266
267 if (matching.value.revokedAt !== null) {
268 return yield* invalidBootstrapCredentialError(
269 "Bootstrap credential is no longer available.",
270 );
271 }
272
273 if (matching.value.consumedAt !== null) {
274 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
275 }
276
277 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
278 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
279 }
280
281 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
282 }).pipe(
283 Effect.mapError((cause) =>
284 cause instanceof BootstrapCredentialError
285 ? cause
286 : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
287 ),
288 );
289
290 return {
291 issueOneTimeToken,
292 listActive,
293 get streamChanges() {
294 return Stream.fromPubSub(changesPubSub);
295 },
296 revoke,
297 consume,
298 } satisfies BootstrapCredentialServiceShape;
299});
300
301export const BootstrapCredentialServiceLive = Layer.effect(
302 BootstrapCredentialService,
303 makeBootstrapCredentialService,
304).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive));
apps/server/src/auth/Layers/ServerAuth.test.ts deleted-183
......@@ -1,183 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
10import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts";
11import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts";
12import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
13
14const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) =>
15 Layer.effect(
16 ServerConfig,
17 Effect.gen(function* () {
18 const config = yield* ServerConfig;
19 return {
20 ...config,
21 ...overrides,
22 } satisfies ServerConfigShape;
23 }),
24 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
25
26const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) =>
27 ServerAuthLive.pipe(
28 Layer.provide(SqlitePersistenceMemory),
29 Layer.provide(ServerSecretStoreLive),
30 Layer.provide(makeServerConfigLayer(overrides)),
31 );
32
33const makeCookieRequest = (
34 sessionToken: string,
35): Parameters<ServerAuthShape["authenticateHttpRequest"]>[0] =>
36 ({
37 cookies: {
38 t3_session: sessionToken,
39 },
40 headers: {},
41 }) as unknown as Parameters<ServerAuthShape["authenticateHttpRequest"]>[0];
42
43const requestMetadata = {
44 deviceType: "desktop" as const,
45 os: "macOS",
46 browser: "Chrome",
47 ipAddress: "192.168.1.23",
48};
49
50it.layer(NodeServices.layer)("ServerAuthLive", (it) => {
51 it.effect("maps invalid bootstrap credential failures to 401", () =>
52 Effect.sync(() => {
53 const error = toBootstrapExchangeAuthError(
54 new BootstrapCredentialError({
55 message: "Unknown bootstrap credential.",
56 status: 401,
57 }),
58 );
59
60 expect(error.status).toBe(401);
61 expect(error.message).toBe("Invalid bootstrap credential.");
62 }),
63 );
64
65 it.effect("maps unexpected bootstrap failures to 500", () =>
66 Effect.sync(() => {
67 const error = toBootstrapExchangeAuthError(
68 new BootstrapCredentialError({
69 message: "Failed to consume bootstrap credential.",
70 status: 500,
71 cause: new Error("sqlite is unavailable"),
72 }),
73 );
74
75 expect(error.status).toBe(500);
76 expect(error.message).toBe("Failed to validate bootstrap credential.");
77 }),
78 );
79
80 it.effect("issues client pairing credentials by default", () =>
81 Effect.gen(function* () {
82 const serverAuth = yield* ServerAuth;
83
84 const pairingCredential = yield* serverAuth.issuePairingCredential();
85 const exchanged = yield* serverAuth.exchangeBootstrapCredential(
86 pairingCredential.credential,
87 requestMetadata,
88 );
89 const verified = yield* serverAuth.authenticateHttpRequest(
90 makeCookieRequest(exchanged.sessionToken),
91 );
92
93 expect(verified.sessionId.length).toBeGreaterThan(0);
94 expect(verified.role).toBe("client");
95 expect(verified.subject).toBe("one-time-token");
96 }).pipe(Effect.provide(makeServerAuthLayer())),
97 );
98
99 it.effect("issues startup pairing URLs that bootstrap owner sessions", () =>
100 Effect.gen(function* () {
101 const serverAuth = yield* ServerAuth;
102
103 const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
104 const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
105 const listedPairingLinks = yield* serverAuth.listPairingLinks();
106 expect(token).toBeTruthy();
107 expect(
108 listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"),
109 ).toBe(false);
110
111 const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata);
112 const verified = yield* serverAuth.authenticateHttpRequest(
113 makeCookieRequest(exchanged.sessionToken),
114 );
115
116 expect(verified.role).toBe("owner");
117 expect(verified.subject).toBe("owner-bootstrap");
118 }).pipe(Effect.provide(makeServerAuthLayer())),
119 );
120
121 it.effect("lists pairing links and revokes other client sessions while keeping the owner", () =>
122 Effect.gen(function* () {
123 const serverAuth = yield* ServerAuth;
124
125 const ownerExchange = yield* serverAuth.exchangeBootstrapCredential(
126 "desktop-bootstrap-token",
127 requestMetadata,
128 );
129 const ownerSession = yield* serverAuth.authenticateHttpRequest(
130 makeCookieRequest(ownerExchange.sessionToken),
131 );
132 const pairingCredential = yield* serverAuth.issuePairingCredential({
133 label: "Julius iPhone",
134 });
135 const listedPairingLinks = yield* serverAuth.listPairingLinks();
136 const clientExchange = yield* serverAuth.exchangeBootstrapCredential(
137 pairingCredential.credential,
138 {
139 ...requestMetadata,
140 deviceType: "mobile",
141 os: "iOS",
142 browser: "Safari",
143 ipAddress: "192.168.1.88",
144 },
145 );
146 const clientSession = yield* serverAuth.authenticateHttpRequest(
147 makeCookieRequest(clientExchange.sessionToken),
148 );
149 const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
150 const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId);
151 const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
152
153 expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id);
154 expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe(
155 "Julius iPhone",
156 );
157 expect(clientsBeforeRevoke).toHaveLength(2);
158 expect(
159 clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current,
160 ).toBe(true);
161 expect(
162 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current,
163 ).toBe(false);
164 expect(
165 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
166 .label,
167 ).toBe("Julius iPhone");
168 expect(
169 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
170 .deviceType,
171 ).toBe("mobile");
172 expect(revokedCount).toBe(1);
173 expect(clientsAfterRevoke).toHaveLength(1);
174 expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId);
175 }).pipe(
176 Effect.provide(
177 makeServerAuthLayer({
178 desktopBootstrapToken: "desktop-bootstrap-token",
179 }),
180 ),
181 ),
182 );
183});
apps/server/src/auth/Layers/ServerAuth.ts deleted-398
......@@ -1,398 +0,0 @@
1import {
2 type AuthBearerBootstrapResult,
3 type AuthClientSession,
4 type AuthBootstrapResult,
5 type AuthPairingCredentialResult,
6 type AuthSessionState,
7 type AuthWebSocketTokenResult,
8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
14
15import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
16import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
17import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
18import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
19import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
20import {
21 ServerAuth,
22 type AuthenticatedSession,
23 AuthError,
24 type ServerAuthShape,
25} from "../Services/ServerAuth.ts";
26import {
27 SessionCredentialError,
28 SessionCredentialService,
29} from "../Services/SessionCredentialService.ts";
30import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";
31
32type BootstrapExchangeResult = {
33 readonly response: AuthBootstrapResult;
34 readonly sessionToken: string;
35};
36
37const AUTHORIZATION_PREFIX = "Bearer ";
38const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken";
39
40export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError {
41 if (cause.status === 500) {
42 return new AuthError({
43 message: "Failed to validate bootstrap credential.",
44 status: 500,
45 cause,
46 });
47 }
48
49 return new AuthError({
50 message: "Invalid bootstrap credential.",
51 status: 401,
52 cause,
53 });
54}
55
56function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
57 const header = request.headers["authorization"];
58 if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) {
59 return null;
60 }
61 const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
62 return token.length > 0 ? token : null;
63}
64
65export const makeServerAuth = Effect.gen(function* () {
66 const policy = yield* ServerAuthPolicy;
67 const bootstrapCredentials = yield* BootstrapCredentialService;
68 const authControlPlane = yield* AuthControlPlane;
69 const sessions = yield* SessionCredentialService;
70 const descriptor = yield* policy.getDescriptor();
71
72 const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>
73 sessions.verify(token).pipe(
74 Effect.tapError((cause: SessionCredentialError) =>
75 Effect.logWarning("Rejected authenticated session credential.").pipe(
76 Effect.annotateLogs({
77 reason: cause.message,
78 }),
79 ),
80 ),
81 Effect.map((session) => ({
82 sessionId: session.sessionId,
83 subject: session.subject,
84 method: session.method,
85 role: session.role,
86 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
87 })),
88 Effect.mapError(
89 (cause) =>
90 new AuthError({
91 message: "Unauthorized request.",
92 status: 401,
93 cause,
94 }),
95 ),
96 );
97
98 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
99 const cookieToken = request.cookies[sessions.cookieName];
100 const bearerToken = parseBearerToken(request);
101 const credential = cookieToken ?? bearerToken;
102 if (!credential) {
103 return Effect.fail(
104 new AuthError({
105 message: "Authentication required.",
106 status: 401,
107 }),
108 );
109 }
110 return authenticateToken(credential);
111 };
112
113 const getSessionState: ServerAuthShape["getSessionState"] = (request) =>
114 authenticateRequest(request).pipe(
115 Effect.map(
116 (session) =>
117 ({
118 authenticated: true,
119 auth: descriptor,
120 role: session.role,
121 sessionMethod: session.method,
122 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
123 }) satisfies AuthSessionState,
124 ),
125 Effect.catchTag("AuthError", () =>
126 Effect.succeed({
127 authenticated: false,
128 auth: descriptor,
129 } satisfies AuthSessionState),
130 ),
131 );
132
133 const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = (
134 credential,
135 requestMetadata,
136 ) =>
137 bootstrapCredentials.consume(credential).pipe(
138 Effect.mapError(toBootstrapExchangeAuthError),
139 Effect.flatMap((grant) =>
140 sessions
141 .issue({
142 method: "browser-session-cookie",
143 subject: grant.subject,
144 role: grant.role,
145 client: {
146 ...requestMetadata,
147 ...(grant.label ? { label: grant.label } : {}),
148 },
149 })
150 .pipe(
151 Effect.mapError(
152 (cause) =>
153 new AuthError({
154 message: "Failed to issue authenticated session.",
155 cause,
156 }),
157 ),
158 ),
159 ),
160 Effect.map(
161 (session) =>
162 ({
163 response: {
164 authenticated: true,
165 role: session.role,
166 sessionMethod: session.method,
167 expiresAt: DateTime.toUtc(session.expiresAt),
168 } satisfies AuthBootstrapResult,
169 sessionToken: session.token,
170 }) satisfies BootstrapExchangeResult,
171 ),
172 );
173
174 const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] =
175 (credential, requestMetadata) =>
176 bootstrapCredentials.consume(credential).pipe(
177 Effect.mapError(toBootstrapExchangeAuthError),
178 Effect.flatMap((grant) =>
179 sessions
180 .issue({
181 method: "bearer-session-token",
182 subject: grant.subject,
183 role: grant.role,
184 client: {
185 ...requestMetadata,
186 ...(grant.label ? { label: grant.label } : {}),
187 },
188 })
189 .pipe(
190 Effect.mapError(
191 (cause) =>
192 new AuthError({
193 message: "Failed to issue authenticated session.",
194 cause,
195 }),
196 ),
197 ),
198 ),
199 Effect.map(
200 (session) =>
201 ({
202 authenticated: true,
203 role: session.role,
204 sessionMethod: "bearer-session-token",
205 expiresAt: DateTime.toUtc(session.expiresAt),
206 sessionToken: session.token,
207 }) satisfies AuthBearerBootstrapResult,
208 ),
209 );
210
211 const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) =>
212 authControlPlane
213 .createPairingLink({
214 role: input?.role ?? "client",
215 subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token",
216 ...(input?.label ? { label: input.label } : {}),
217 })
218 .pipe(
219 Effect.mapError(
220 (cause) =>
221 new AuthError({
222 message: "Failed to issue pairing credential.",
223 cause,
224 }),
225 ),
226 Effect.map(
227 (issued) =>
228 ({
229 id: issued.id,
230 credential: issued.credential,
231 ...(issued.label ? { label: issued.label } : {}),
232 expiresAt: issued.expiresAt,
233 }) satisfies AuthPairingCredentialResult,
234 ),
235 );
236
237 const listPairingLinks: ServerAuthShape["listPairingLinks"] = () =>
238 authControlPlane
239 .listPairingLinks({
240 role: "client",
241 excludeSubjects: ["owner-bootstrap"],
242 })
243 .pipe(
244 Effect.mapError(
245 (cause) =>
246 new AuthError({
247 message: "Failed to load pairing links.",
248 cause,
249 }),
250 ),
251 );
252
253 const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) =>
254 authControlPlane.revokePairingLink(id).pipe(
255 Effect.mapError(
256 (cause) =>
257 new AuthError({
258 message: "Failed to revoke pairing link.",
259 cause,
260 }),
261 ),
262 );
263
264 const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) =>
265 authControlPlane.listSessions().pipe(
266 Effect.mapError(
267 (cause) =>
268 new AuthError({
269 message: "Failed to load paired clients.",
270 cause,
271 }),
272 ),
273 Effect.map((clientSessions) =>
274 clientSessions.map(
275 (clientSession): AuthClientSession => ({
276 ...clientSession,
277 current: clientSession.sessionId === currentSessionId,
278 }),
279 ),
280 ),
281 );
282
283 const revokeClientSession: ServerAuthShape["revokeClientSession"] = (
284 currentSessionId,
285 targetSessionId,
286 ) =>
287 Effect.gen(function* () {
288 if (currentSessionId === targetSessionId) {
289 return yield* new AuthError({
290 message: "Use revoke other clients to keep the current owner session active.",
291 status: 403,
292 });
293 }
294 return yield* authControlPlane.revokeSession(targetSessionId).pipe(
295 Effect.mapError(
296 (cause) =>
297 new AuthError({
298 message: "Failed to revoke client session.",
299 cause,
300 }),
301 ),
302 );
303 });
304
305 const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = (
306 currentSessionId,
307 ) =>
308 authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe(
309 Effect.mapError(
310 (cause) =>
311 new AuthError({
312 message: "Failed to revoke other client sessions.",
313 cause,
314 }),
315 ),
316 );
317
318 const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
319 issuePairingCredential({ role: "owner" }).pipe(
320 Effect.map((issued) => {
321 const url = new URL(baseUrl);
322 url.pathname = "/pair";
323 url.searchParams.delete("token");
324 url.hash = new URLSearchParams([["token", issued.credential]]).toString();
325 return url.toString();
326 }),
327 );
328
329 const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) =>
330 sessions.issueWebSocketToken(session.sessionId).pipe(
331 Effect.mapError(
332 (cause) =>
333 new AuthError({
334 message: "Failed to issue websocket token.",
335 cause,
336 }),
337 ),
338 Effect.map(
339 (issued) =>
340 ({
341 token: issued.token,
342 expiresAt: DateTime.toUtc(issued.expiresAt),
343 }) satisfies AuthWebSocketTokenResult,
344 ),
345 );
346
347 const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) =>
348 Effect.gen(function* () {
349 const requestUrl = HttpServerRequest.toURL(request);
350 if (Option.isSome(requestUrl)) {
351 const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM);
352 if (websocketToken && websocketToken.trim().length > 0) {
353 return yield* sessions.verifyWebSocketToken(websocketToken).pipe(
354 Effect.map((session) => ({
355 sessionId: session.sessionId,
356 subject: session.subject,
357 method: session.method,
358 role: session.role,
359 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
360 })),
361 Effect.mapError(
362 (cause) =>
363 new AuthError({
364 message: "Unauthorized request.",
365 status: 401,
366 cause,
367 }),
368 ),
369 );
370 }
371 }
372
373 return yield* authenticateRequest(request);
374 });
375
376 return {
377 getDescriptor: () => Effect.succeed(descriptor),
378 getSessionState,
379 exchangeBootstrapCredential,
380 exchangeBootstrapCredentialForBearerSession,
381 issuePairingCredential,
382 listPairingLinks,
383 revokePairingLink,
384 listClientSessions,
385 revokeClientSession,
386 revokeOtherClientSessions,
387 authenticateHttpRequest: authenticateRequest,
388 authenticateWebSocketUpgrade,
389 issueWebSocketToken,
390 issueStartupPairingUrl,
391 } satisfies ServerAuthShape;
392});
393
394export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe(
395 Layer.provideMerge(AuthControlPlaneLive),
396 Layer.provideMerge(AuthCoreLive),
397 Layer.provideMerge(ServerAuthPolicyLive),
398);
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts deleted-115
......@@ -1,115 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
9import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
10
11const makeServerAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) =>
12 ServerAuthPolicyLive.pipe(
13 Layer.provide(
14 Layer.effect(
15 ServerConfig,
16 Effect.gen(function* () {
17 const config = yield* ServerConfig;
18 return {
19 ...config,
20 ...overrides,
21 } satisfies ServerConfigShape;
22 }),
23 ).pipe(
24 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })),
25 ),
26 ),
27 );
28
29it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {
30 it.effect("uses desktop-managed-local policy for desktop mode", () =>
31 Effect.gen(function* () {
32 const policy = yield* ServerAuthPolicy;
33 const descriptor = yield* policy.getDescriptor();
34
35 expect(descriptor.policy).toBe("desktop-managed-local");
36 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
37 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
38 }).pipe(
39 Effect.provide(
40 makeServerAuthPolicyLayer({
41 mode: "desktop",
42 port: 3773,
43 }),
44 ),
45 ),
46 );
47
48 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
49 Effect.gen(function* () {
50 const policy = yield* ServerAuthPolicy;
51 const descriptor = yield* policy.getDescriptor();
52
53 expect(descriptor.policy).toBe("remote-reachable");
54 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]);
55 }).pipe(
56 Effect.provide(
57 makeServerAuthPolicyLayer({
58 mode: "desktop",
59 host: "0.0.0.0",
60 }),
61 ),
62 ),
63 );
64
65 it.effect("uses loopback-browser policy for loopback web hosts", () =>
66 Effect.gen(function* () {
67 const policy = yield* ServerAuthPolicy;
68 const descriptor = yield* policy.getDescriptor();
69
70 expect(descriptor.policy).toBe("loopback-browser");
71 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
72 expect(descriptor.sessionCookieName).toBe("t3_session");
73 }).pipe(
74 Effect.provide(
75 makeServerAuthPolicyLayer({
76 mode: "web",
77 host: "127.0.0.1",
78 }),
79 ),
80 ),
81 );
82
83 it.effect("uses remote-reachable policy for wildcard web hosts", () =>
84 Effect.gen(function* () {
85 const policy = yield* ServerAuthPolicy;
86 const descriptor = yield* policy.getDescriptor();
87
88 expect(descriptor.policy).toBe("remote-reachable");
89 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
90 }).pipe(
91 Effect.provide(
92 makeServerAuthPolicyLayer({
93 mode: "web",
94 host: "0.0.0.0",
95 }),
96 ),
97 ),
98 );
99
100 it.effect("uses remote-reachable policy for non-loopback web hosts", () =>
101 Effect.gen(function* () {
102 const policy = yield* ServerAuthPolicy;
103 const descriptor = yield* policy.getDescriptor();
104
105 expect(descriptor.policy).toBe("remote-reachable");
106 }).pipe(
107 Effect.provide(
108 makeServerAuthPolicyLayer({
109 mode: "web",
110 host: "192.168.1.50",
111 }),
112 ),
113 ),
114 );
115});
apps/server/src/auth/Layers/ServerAuthPolicy.ts deleted-45
......@@ -1,45 +0,0 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import { ServerConfig } from "../../config.ts";
6import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
7import { resolveSessionCookieName } from "../utils.ts";
8import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
9
10export const makeServerAuthPolicy = Effect.gen(function* () {
11 const config = yield* ServerConfig;
12 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
13
14 const policy =
15 config.mode === "desktop"
16 ? isRemoteReachable
17 ? "remote-reachable"
18 : "desktop-managed-local"
19 : isRemoteReachable
20 ? "remote-reachable"
21 : "loopback-browser";
22
23 const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] =
24 policy === "desktop-managed-local"
25 ? ["desktop-bootstrap"]
26 : config.mode === "desktop" && policy === "remote-reachable"
27 ? ["desktop-bootstrap", "one-time-token"]
28 : ["one-time-token"];
29
30 const descriptor: ServerAuthDescriptor = {
31 policy,
32 bootstrapMethods,
33 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
34 sessionCookieName: resolveSessionCookieName({
35 mode: config.mode,
36 port: config.port,
37 }),
38 };
39
40 return {
41 getDescriptor: () => Effect.succeed(descriptor),
42 } satisfies ServerAuthPolicyShape;
43});
44
45export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy);
apps/server/src/auth/Layers/ServerSecretStore.test.ts deleted-268
......@@ -1,268 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Ref from "effect/Ref";
9import * as PlatformError from "effect/PlatformError";
10
11import { ServerConfig } from "../../config.ts";
12import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts";
13import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
14
15const makeServerConfigLayer = () =>
16 ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" });
17
18const makeServerSecretStoreLayer = () =>
19 Layer.provide(ServerSecretStoreLive, makeServerConfigLayer());
20
21const PermissionDeniedFileSystemLayer = Layer.effect(
22 FileSystem.FileSystem,
23 Effect.gen(function* () {
24 const fileSystem = yield* FileSystem.FileSystem;
25
26 return {
27 ...fileSystem,
28 readFile: (path) =>
29 Effect.fail(
30 PlatformError.systemError({
31 _tag: "PermissionDenied",
32 module: "FileSystem",
33 method: "readFile",
34 pathOrDescriptor: path,
35 description: "Permission denied while reading secret file.",
36 }),
37 ),
38 } satisfies FileSystem.FileSystem;
39 }),
40).pipe(Layer.provide(NodeServices.layer));
41
42const makePermissionDeniedSecretStoreLayer = () =>
43 ServerSecretStoreLive.pipe(
44 Layer.provide(makeServerConfigLayer()),
45 Layer.provideMerge(PermissionDeniedFileSystemLayer),
46 );
47
48const RenameFailureFileSystemLayer = Layer.effect(
49 FileSystem.FileSystem,
50 Effect.gen(function* () {
51 const fileSystem = yield* FileSystem.FileSystem;
52
53 return {
54 ...fileSystem,
55 rename: (from, to) =>
56 Effect.fail(
57 PlatformError.systemError({
58 _tag: "PermissionDenied",
59 module: "FileSystem",
60 method: "rename",
61 pathOrDescriptor: `${String(from)} -> ${String(to)}`,
62 description: "Permission denied while persisting secret file.",
63 }),
64 ),
65 } satisfies FileSystem.FileSystem;
66 }),
67).pipe(Layer.provide(NodeServices.layer));
68
69const makeRenameFailureSecretStoreLayer = () =>
70 ServerSecretStoreLive.pipe(
71 Layer.provide(makeServerConfigLayer()),
72 Layer.provideMerge(RenameFailureFileSystemLayer),
73 );
74
75const RemoveFailureFileSystemLayer = Layer.effect(
76 FileSystem.FileSystem,
77 Effect.gen(function* () {
78 const fileSystem = yield* FileSystem.FileSystem;
79
80 return {
81 ...fileSystem,
82 remove: (path, options) =>
83 Effect.fail(
84 PlatformError.systemError({
85 _tag: "PermissionDenied",
86 module: "FileSystem",
87 method: "remove",
88 pathOrDescriptor: String(path),
89 description: `Permission denied while removing secret file.${options ? " options-set" : ""}`,
90 }),
91 ),
92 } satisfies FileSystem.FileSystem;
93 }),
94).pipe(Layer.provide(NodeServices.layer));
95
96const makeRemoveFailureSecretStoreLayer = () =>
97 ServerSecretStoreLive.pipe(
98 Layer.provide(makeServerConfigLayer()),
99 Layer.provideMerge(RemoveFailureFileSystemLayer),
100 );
101
102const ConcurrentReadMissFileSystemLayer = Layer.effect(
103 FileSystem.FileSystem,
104 Effect.gen(function* () {
105 const fileSystem = yield* FileSystem.FileSystem;
106 const readCountRef = yield* Ref.make(0);
107 const readBarrier = yield* Deferred.make<void>();
108
109 return {
110 ...fileSystem,
111 readFile: (path) =>
112 String(path).endsWith("/session-signing-key.bin")
113 ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe(
114 Effect.flatMap((count) => {
115 if (count > 2) {
116 return fileSystem.readFile(path);
117 }
118 return Effect.gen(function* () {
119 if (count === 2) {
120 yield* Deferred.succeed(readBarrier, void 0);
121 }
122 yield* Deferred.await(readBarrier);
123 return yield* Effect.failCause(
124 Cause.fail(
125 PlatformError.systemError({
126 _tag: "NotFound",
127 module: "FileSystem",
128 method: "readFile",
129 pathOrDescriptor: String(path),
130 description: "Secret file does not exist yet.",
131 }),
132 ),
133 );
134 });
135 }),
136 )
137 : fileSystem.readFile(path),
138 } satisfies FileSystem.FileSystem;
139 }),
140).pipe(Layer.provide(NodeServices.layer));
141
142const makeConcurrentCreateSecretStoreLayer = () =>
143 ServerSecretStoreLive.pipe(
144 Layer.provide(makeServerConfigLayer()),
145 Layer.provideMerge(ConcurrentReadMissFileSystemLayer),
146 );
147
148it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => {
149 it.effect("returns null when a secret file does not exist", () =>
150 Effect.gen(function* () {
151 const secretStore = yield* ServerSecretStore;
152
153 const secret = yield* secretStore.get("missing-secret");
154
155 expect(secret).toBeNull();
156 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
157 );
158
159 it.effect("reuses an existing secret instead of regenerating it", () =>
160 Effect.gen(function* () {
161 const secretStore = yield* ServerSecretStore;
162
163 const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
164 const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
165
166 expect(Array.from(second)).toEqual(Array.from(first));
167 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
168 );
169
170 it.effect("returns the persisted secret when concurrent creators race", () =>
171 Effect.gen(function* () {
172 const secretStore = yield* ServerSecretStore;
173
174 const [first, second] = yield* Effect.all(
175 [
176 secretStore.getOrCreateRandom("session-signing-key", 32),
177 secretStore.getOrCreateRandom("session-signing-key", 32),
178 ],
179 { concurrency: "unbounded" },
180 );
181 const persisted = yield* secretStore.get("session-signing-key");
182
183 expect(persisted).not.toBeNull();
184 expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array()));
185 expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array()));
186 }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())),
187 );
188
189 it.effect("uses restrictive permissions for the secret directory and files", () =>
190 Effect.gen(function* () {
191 const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = [];
192 const recordingFileSystemLayer = Layer.effect(
193 FileSystem.FileSystem,
194 Effect.gen(function* () {
195 const fileSystem = yield* FileSystem.FileSystem;
196
197 return {
198 ...fileSystem,
199 makeDirectory: () => Effect.void,
200 writeFile: () => Effect.void,
201 rename: () => Effect.void,
202 chmod: (path, mode) =>
203 Effect.sync(() => {
204 chmodCalls.push({ path: String(path), mode });
205 }),
206 } satisfies FileSystem.FileSystem;
207 }),
208 ).pipe(Layer.provide(NodeServices.layer));
209
210 const secretStore = yield* Effect.service(ServerSecretStore).pipe(
211 Effect.provide(
212 ServerSecretStoreLive.pipe(
213 Layer.provide(makeServerConfigLayer()),
214 Layer.provideMerge(recordingFileSystemLayer),
215 ),
216 ),
217 );
218
219 yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3]));
220
221 expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe(
222 true,
223 );
224 expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2);
225 }).pipe(Effect.provide(NodeServices.layer)),
226 );
227
228 it.effect("propagates read failures other than missing-file errors", () =>
229 Effect.gen(function* () {
230 const secretStore = yield* ServerSecretStore;
231
232 const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
233
234 expect(error).toBeInstanceOf(SecretStoreError);
235 expect(error.message).toContain("Failed to read secret session-signing-key.");
236 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
237 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
238 }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())),
239 );
240
241 it.effect("propagates write failures instead of treating them as success", () =>
242 Effect.gen(function* () {
243 const secretStore = yield* ServerSecretStore;
244
245 const error = yield* Effect.flip(
246 secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
247 );
248
249 expect(error).toBeInstanceOf(SecretStoreError);
250 expect(error.message).toContain("Failed to persist secret session-signing-key.");
251 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
252 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
253 }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())),
254 );
255
256 it.effect("propagates remove failures other than missing-file errors", () =>
257 Effect.gen(function* () {
258 const secretStore = yield* ServerSecretStore;
259
260 const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
261
262 expect(error).toBeInstanceOf(SecretStoreError);
263 expect(error.message).toContain("Failed to remove secret session-signing-key.");
264 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
265 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
266 }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())),
267 );
268});
apps/server/src/auth/Layers/ServerSecretStore.ts deleted-152
......@@ -1,152 +0,0 @@
1import * as Crypto from "node:crypto";
2
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as Predicate from "effect/Predicate";
8import * as PlatformError from "effect/PlatformError";
9
10import { ServerConfig } from "../../config.ts";
11import {
12 SecretStoreError,
13 ServerSecretStore,
14 type ServerSecretStoreShape,
15} from "../Services/ServerSecretStore.ts";
16
17export const makeServerSecretStore = Effect.gen(function* () {
18 const fileSystem = yield* FileSystem.FileSystem;
19 const path = yield* Path.Path;
20 const serverConfig = yield* ServerConfig;
21
22 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
23 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
24 Effect.mapError(
25 (cause) =>
26 new SecretStoreError({
27 message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
28 cause,
29 }),
30 ),
31 );
32
33 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
34
35 const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
36 Predicate.isTagged(u, "PlatformError");
37
38 const get: ServerSecretStoreShape["get"] = (name) =>
39 fileSystem.readFile(resolveSecretPath(name)).pipe(
40 Effect.map((bytes) => Uint8Array.from(bytes)),
41 Effect.catch((cause) =>
42 cause.reason._tag === "NotFound"
43 ? Effect.succeed(null)
44 : Effect.fail(
45 new SecretStoreError({
46 message: `Failed to read secret ${name}.`,
47 cause,
48 }),
49 ),
50 ),
51 );
52
53 const set: ServerSecretStoreShape["set"] = (name, value) => {
54 const secretPath = resolveSecretPath(name);
55 const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`;
56 return Effect.gen(function* () {
57 yield* fileSystem.writeFile(tempPath, value);
58 yield* fileSystem.chmod(tempPath, 0o600);
59 yield* fileSystem.rename(tempPath, secretPath);
60 yield* fileSystem.chmod(secretPath, 0o600);
61 }).pipe(
62 Effect.catch((cause) =>
63 fileSystem.remove(tempPath).pipe(
64 Effect.ignore,
65 Effect.flatMap(() =>
66 Effect.fail(
67 new SecretStoreError({
68 message: `Failed to persist secret ${name}.`,
69 cause,
70 }),
71 ),
72 ),
73 ),
74 ),
75 );
76 };
77
78 const create: ServerSecretStoreShape["set"] = (name, value) => {
79 const secretPath = resolveSecretPath(name);
80 return Effect.scoped(
81 Effect.gen(function* () {
82 const file = yield* fileSystem.open(secretPath, {
83 flag: "wx",
84 mode: 0o600,
85 });
86 yield* file.writeAll(value);
87 yield* file.sync;
88 yield* fileSystem.chmod(secretPath, 0o600);
89 }),
90 ).pipe(
91 Effect.mapError(
92 (cause) =>
93 new SecretStoreError({
94 message: `Failed to persist secret ${name}.`,
95 cause,
96 }),
97 ),
98 );
99 };
100
101 const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
102 get(name).pipe(
103 Effect.flatMap((existing) => {
104 if (existing) {
105 return Effect.succeed(existing);
106 }
107
108 const generated = Crypto.randomBytes(bytes);
109 return create(name, generated).pipe(
110 Effect.as(Uint8Array.from(generated)),
111 Effect.catchTag("SecretStoreError", (error) =>
112 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
113 ? get(name).pipe(
114 Effect.flatMap((created) =>
115 created !== null
116 ? Effect.succeed(created)
117 : Effect.fail(
118 new SecretStoreError({
119 message: `Failed to read secret ${name} after concurrent creation.`,
120 }),
121 ),
122 ),
123 )
124 : Effect.fail(error),
125 ),
126 );
127 }),
128 );
129
130 const remove: ServerSecretStoreShape["remove"] = (name) =>
131 fileSystem.remove(resolveSecretPath(name)).pipe(
132 Effect.catch((cause) =>
133 cause.reason._tag === "NotFound"
134 ? Effect.void
135 : Effect.fail(
136 new SecretStoreError({
137 message: `Failed to remove secret ${name}.`,
138 cause,
139 }),
140 ),
141 ),
142 );
143
144 return {
145 get,
146 set,
147 getOrCreateRandom,
148 remove,
149 } satisfies ServerSecretStoreShape;
150});
151
152export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore);
apps/server/src/auth/Layers/SessionCredentialService.test.ts deleted-193
......@@ -1,193 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../../config.ts";
9import { ServerConfig } from "../../config.ts";
10import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
12import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
13import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
14
15const makeServerConfigLayer = (
16 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
17) =>
18 Layer.effect(
19 ServerConfig,
20 Effect.gen(function* () {
21 const config = yield* ServerConfig;
22 return {
23 ...config,
24 ...overrides,
25 } satisfies ServerConfigShape;
26 }),
27 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
28
29const makeSessionCredentialLayer = (
30 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
31) =>
32 SessionCredentialServiceLive.pipe(
33 Layer.provide(SqlitePersistenceMemory),
34 Layer.provide(ServerSecretStoreLive),
35 Layer.provide(makeServerConfigLayer(overrides)),
36 );
37
38it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => {
39 it.effect("issues and verifies signed browser session tokens", () =>
40 Effect.gen(function* () {
41 const sessions = yield* SessionCredentialService;
42 const issued = yield* sessions.issue({
43 subject: "desktop-bootstrap",
44 role: "owner",
45 client: {
46 label: "Desktop app",
47 deviceType: "desktop",
48 os: "macOS",
49 browser: "Electron",
50 ipAddress: "127.0.0.1",
51 },
52 });
53 const verified = yield* sessions.verify(issued.token);
54
55 expect(verified.method).toBe("browser-session-cookie");
56 expect(verified.subject).toBe("desktop-bootstrap");
57 expect(verified.role).toBe("owner");
58 expect(verified.client.label).toBe("Desktop app");
59 expect(verified.client.browser).toBe("Electron");
60 expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
61 }).pipe(Effect.provide(makeSessionCredentialLayer())),
62 );
63 it.effect("rejects malformed session tokens", () =>
64 Effect.gen(function* () {
65 const sessions = yield* SessionCredentialService;
66 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
67
68 expect(error._tag).toBe("SessionCredentialError");
69 expect(error.message).toContain("Malformed session token");
70 }).pipe(Effect.provide(makeSessionCredentialLayer())),
71 );
72 it.effect("verifies session tokens against the Effect clock", () =>
73 Effect.gen(function* () {
74 const sessions = yield* SessionCredentialService;
75 const issued = yield* sessions.issue({
76 method: "bearer-session-token",
77 subject: "test-clock",
78 });
79 const verified = yield* sessions.verify(issued.token);
80
81 expect(verified.method).toBe("bearer-session-token");
82 expect(verified.subject).toBe("test-clock");
83 expect(verified.role).toBe("client");
84 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
85 );
86
87 it.effect("rejects websocket tokens once the parent session has expired", () =>
88 Effect.gen(function* () {
89 const sessions = yield* SessionCredentialService;
90 const issued = yield* sessions.issue({
91 method: "bearer-session-token",
92 subject: "short-lived",
93 ttl: Duration.seconds(1),
94 });
95 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
96
97 yield* TestClock.adjust(Duration.seconds(2));
98
99 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
100 expect(error.message).toContain("expired");
101 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
102 );
103
104 it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
105 Effect.gen(function* () {
106 const sessions = yield* SessionCredentialService;
107 const owner = yield* sessions.issue({
108 subject: "desktop-bootstrap",
109 role: "owner",
110 client: {
111 label: "Desktop app",
112 deviceType: "desktop",
113 os: "macOS",
114 browser: "Electron",
115 },
116 });
117 const client = yield* sessions.issue({
118 subject: "one-time-token",
119 role: "client",
120 client: {
121 label: "Julius iPhone",
122 deviceType: "mobile",
123 os: "iOS",
124 browser: "Safari",
125 ipAddress: "192.168.1.88",
126 },
127 });
128
129 yield* sessions.markConnected(client.sessionId);
130 const beforeRevoke = yield* sessions.listActive();
131 const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId);
132 const afterRevoke = yield* sessions.listActive();
133 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
134
135 expect(beforeRevoke).toHaveLength(2);
136 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
137 true,
138 );
139 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
140 "Julius iPhone",
141 );
142 expect(
143 beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType,
144 ).toBe("desktop");
145 expect(revokedCount).toBe(1);
146 expect(afterRevoke).toHaveLength(1);
147 expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId);
148 expect(revokedClient.message).toContain("revoked");
149 }).pipe(Effect.provide(makeSessionCredentialLayer())),
150 );
151
152 it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>
153 Effect.gen(function* () {
154 const sessions = yield* SessionCredentialService;
155 const issued = yield* sessions.issue({
156 subject: "reconnect-test",
157 method: "bearer-session-token",
158 });
159
160 const beforeConnect = yield* sessions.listActive();
161 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
162
163 yield* TestClock.adjust(Duration.seconds(1));
164 yield* sessions.markConnected(issued.sessionId);
165 const firstConnect = yield* sessions.listActive();
166 const firstConnectedAt = firstConnect[0]?.lastConnectedAt;
167
168 expect(firstConnect[0]?.connected).toBe(true);
169 expect(firstConnectedAt).not.toBeNull();
170
171 yield* TestClock.adjust(Duration.seconds(1));
172 yield* sessions.markConnected(issued.sessionId);
173 const stillConnected = yield* sessions.listActive();
174
175 expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
176
177 yield* sessions.markDisconnected(issued.sessionId);
178 yield* sessions.markDisconnected(issued.sessionId);
179 const afterDisconnect = yield* sessions.listActive();
180
181 expect(afterDisconnect[0]?.connected).toBe(false);
182 expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
183
184 yield* TestClock.adjust(Duration.seconds(1));
185 yield* sessions.markConnected(issued.sessionId);
186 const afterReconnect = yield* sessions.listActive();
187
188 expect(afterReconnect[0]?.connected).toBe(true);
189 expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();
190 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
191 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
192 );
193});
apps/server/src/auth/Layers/SessionCredentialService.ts deleted-530
......@@ -1,530 +0,0 @@
1import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts";
2import * as Clock from "effect/Clock";
3import * as Crypto from "effect/Crypto";
4import * as DateTime from "effect/DateTime";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as PubSub from "effect/PubSub";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import * as Option from "effect/Option";
13
14import { ServerConfig } from "../../config.ts";
15import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
16import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
17import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
18import {
19 SessionCredentialError,
20 SessionCredentialService,
21 type IssuedSession,
22 type SessionCredentialChange,
23 type SessionCredentialServiceShape,
24 type VerifiedSession,
25} from "../Services/SessionCredentialService.ts";
26import {
27 base64UrlDecodeUtf8,
28 base64UrlEncode,
29 resolveSessionCookieName,
30 signPayload,
31 timingSafeEqualBase64Url,
32} from "../utils.ts";
33
34const SIGNING_SECRET_NAME = "server-signing-key";
35const DEFAULT_SESSION_TTL = Duration.days(30);
36const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
37
38const SessionClaims = Schema.Struct({
39 v: Schema.Literal(1),
40 kind: Schema.Literal("session"),
41 sid: AuthSessionId,
42 sub: Schema.String,
43 role: Schema.Literals(["owner", "client"]),
44 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
45 iat: Schema.Number,
46 exp: Schema.Number,
47});
48type SessionClaims = typeof SessionClaims.Type;
49
50const WebSocketClaims = Schema.Struct({
51 v: Schema.Literal(1),
52 kind: Schema.Literal("websocket"),
53 sid: AuthSessionId,
54 iat: Schema.Number,
55 exp: Schema.Number,
56});
57type WebSocketClaims = typeof WebSocketClaims.Type;
58
59const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
60const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
61
62function createDefaultClientMetadata(): AuthClientMetadata {
63 return {
64 deviceType: "unknown",
65 };
66}
67
68function toClientMetadata(record: {
69 readonly label: string | null;
70 readonly ipAddress: string | null;
71 readonly userAgent: string | null;
72 readonly deviceType: AuthClientMetadata["deviceType"];
73 readonly os: string | null;
74 readonly browser: string | null;
75}): AuthClientMetadata {
76 return {
77 ...(record.label ? { label: record.label } : {}),
78 ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
79 ...(record.userAgent ? { userAgent: record.userAgent } : {}),
80 deviceType: record.deviceType,
81 ...(record.os ? { os: record.os } : {}),
82 ...(record.browser ? { browser: record.browser } : {}),
83 };
84}
85
86function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
87 return {
88 ...input,
89 current: false,
90 };
91}
92
93export const makeSessionCredentialService = Effect.gen(function* () {
94 const crypto = yield* Crypto.Crypto;
95 const serverConfig = yield* ServerConfig;
96 const secretStore = yield* ServerSecretStore;
97 const authSessions = yield* AuthSessionRepository;
98 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
99 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
100 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
101 const cookieName = resolveSessionCookieName({
102 mode: serverConfig.mode,
103 port: serverConfig.port,
104 });
105
106 const toSessionCredentialError = (message: string) => (cause: unknown) =>
107 new SessionCredentialError({
108 message,
109 cause,
110 });
111
112 const emitUpsert = (clientSession: AuthClientSession) =>
113 PubSub.publish(changesPubSub, {
114 type: "clientUpserted",
115 clientSession,
116 }).pipe(Effect.asVoid);
117
118 const emitRemoved = (sessionId: AuthSessionId) =>
119 PubSub.publish(changesPubSub, {
120 type: "clientRemoved",
121 sessionId,
122 }).pipe(Effect.asVoid);
123
124 const loadActiveSession = (sessionId: AuthSessionId) =>
125 Effect.gen(function* () {
126 const row = yield* authSessions.getById({ sessionId });
127 if (Option.isNone(row) || row.value.revokedAt !== null) {
128 return Option.none<AuthClientSession>();
129 }
130
131 const connectedSessions = yield* Ref.get(connectedSessionsRef);
132 return Option.some(
133 toAuthClientSession({
134 sessionId: row.value.sessionId,
135 subject: row.value.subject,
136 role: row.value.role,
137 method: row.value.method,
138 client: toClientMetadata(row.value.client),
139 issuedAt: row.value.issuedAt,
140 expiresAt: row.value.expiresAt,
141 lastConnectedAt: row.value.lastConnectedAt,
142 connected: connectedSessions.has(row.value.sessionId),
143 }),
144 );
145 });
146
147 const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) =>
148 Ref.modify(connectedSessionsRef, (current) => {
149 const next = new Map(current);
150 const wasDisconnected = !next.has(sessionId);
151 next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
152 return [wasDisconnected, next] as const;
153 }).pipe(
154 Effect.flatMap((wasDisconnected) =>
155 wasDisconnected
156 ? DateTime.now.pipe(
157 Effect.flatMap((lastConnectedAt) =>
158 authSessions.setLastConnectedAt({
159 sessionId,
160 lastConnectedAt,
161 }),
162 ),
163 )
164 : Effect.void,
165 ),
166 Effect.flatMap(() => loadActiveSession(sessionId)),
167 Effect.flatMap((session) =>
168 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
169 ),
170 Effect.catchCause((cause) =>
171 Effect.logError("Failed to publish connected-session auth update.").pipe(
172 Effect.annotateLogs({
173 sessionId,
174 cause,
175 }),
176 ),
177 ),
178 );
179
180 const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) =>
181 Ref.update(connectedSessionsRef, (current) => {
182 const next = new Map(current);
183 const remaining = (next.get(sessionId) ?? 0) - 1;
184 if (remaining > 0) {
185 next.set(sessionId, remaining);
186 } else {
187 next.delete(sessionId);
188 }
189 return next;
190 }).pipe(
191 Effect.flatMap(() => loadActiveSession(sessionId)),
192 Effect.flatMap((session) =>
193 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
194 ),
195 Effect.catchCause((cause) =>
196 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
197 Effect.annotateLogs({
198 sessionId,
199 cause,
200 }),
201 ),
202 ),
203 );
204
205 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
206 const issue: SessionCredentialServiceShape["issue"] = (input) =>
207 Effect.gen(function* () {
208 const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4);
209 const issuedAt = yield* DateTime.now;
210 const expiresAt = DateTime.add(issuedAt, {
211 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
212 });
213 const claims: SessionClaims = {
214 v: 1,
215 kind: "session",
216 sid: sessionId,
217 sub: input?.subject ?? "browser",
218 role: input?.role ?? "client",
219 method: input?.method ?? "browser-session-cookie",
220 iat: issuedAt.epochMilliseconds,
221 exp: expiresAt.epochMilliseconds,
222 };
223
224 const encodedPayload = yield* encodeClaims(claims).pipe(
225 Effect.map(base64UrlEncode),
226 Effect.mapError(
227 (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }),
228 ),
229 );
230 const signature = signPayload(encodedPayload, signingSecret);
231 const client = input?.client ?? createDefaultClientMetadata();
232 yield* authSessions.create({
233 sessionId,
234 subject: claims.sub,
235 role: claims.role,
236 method: claims.method,
237 client: {
238 label: client.label ?? null,
239 ipAddress: client.ipAddress ?? null,
240 userAgent: client.userAgent ?? null,
241 deviceType: client.deviceType,
242 os: client.os ?? null,
243 browser: client.browser ?? null,
244 },
245 issuedAt,
246 expiresAt,
247 });
248 yield* emitUpsert(
249 toAuthClientSession({
250 sessionId,
251 subject: claims.sub,
252 role: claims.role,
253 method: claims.method,
254 client,
255 issuedAt,
256 expiresAt,
257 lastConnectedAt: null,
258 connected: false,
259 }),
260 );
261
262 return {
263 sessionId,
264 token: `${encodedPayload}.${signature}`,
265 method: claims.method,
266 client,
267 expiresAt: expiresAt,
268 role: claims.role,
269 } satisfies IssuedSession;
270 }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential.")));
271
272 const verify: SessionCredentialServiceShape["verify"] = (token) =>
273 Effect.gen(function* () {
274 const [encodedPayload, signature] = token.split(".");
275 if (!encodedPayload || !signature) {
276 return yield* new SessionCredentialError({
277 message: "Malformed session token.",
278 });
279 }
280
281 const expectedSignature = signPayload(encodedPayload, signingSecret);
282 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
283 return yield* new SessionCredentialError({
284 message: "Invalid session token signature.",
285 });
286 }
287
288 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
289 Effect.mapError(
290 (cause) =>
291 new SessionCredentialError({
292 message: "Invalid session token payload.",
293 cause,
294 }),
295 ),
296 );
297
298 const now = yield* Clock.currentTimeMillis;
299 if (claims.exp <= now) {
300 return yield* new SessionCredentialError({
301 message: "Session token expired.",
302 });
303 }
304
305 const row = yield* authSessions.getById({ sessionId: claims.sid });
306 if (Option.isNone(row)) {
307 return yield* new SessionCredentialError({
308 message: "Unknown session token.",
309 });
310 }
311 if (row.value.revokedAt !== null) {
312 return yield* new SessionCredentialError({
313 message: "Session token revoked.",
314 });
315 }
316
317 const expiresAt = DateTime.make(claims.exp);
318 if (Option.isNone(expiresAt)) {
319 return yield* new SessionCredentialError({
320 message: "Invalid `exp` claim",
321 });
322 }
323
324 return {
325 sessionId: claims.sid,
326 token,
327 method: claims.method,
328 client: toClientMetadata(row.value.client),
329 expiresAt: expiresAt.value,
330 subject: claims.sub,
331 role: claims.role,
332 } satisfies VerifiedSession;
333 }).pipe(
334 Effect.mapError((cause) =>
335 cause instanceof SessionCredentialError
336 ? cause
337 : new SessionCredentialError({
338 message: "Failed to verify session credential.",
339 cause,
340 }),
341 ),
342 );
343
344 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
345 const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = (
346 sessionId,
347 input,
348 ) =>
349 Effect.gen(function* () {
350 const issuedAt = yield* DateTime.now;
351 const expiresAt = DateTime.add(issuedAt, {
352 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
353 });
354 const claims: WebSocketClaims = {
355 v: 1,
356 kind: "websocket",
357 sid: sessionId,
358 iat: issuedAt.epochMilliseconds,
359 exp: expiresAt.epochMilliseconds,
360 };
361 const encodedPayload = yield* encodeWsClaims(claims).pipe(
362 Effect.map(base64UrlEncode),
363 Effect.mapError(
364 (cause) => new SessionCredentialError({ message: "Failed to encode claims", cause }),
365 ),
366 );
367 const signature = signPayload(encodedPayload, signingSecret);
368 return {
369 token: `${encodedPayload}.${signature}`,
370 expiresAt,
371 };
372 }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token.")));
373
374 const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) =>
375 Effect.gen(function* () {
376 const [encodedPayload, signature] = token.split(".");
377 if (!encodedPayload || !signature) {
378 return yield* new SessionCredentialError({
379 message: "Malformed websocket token.",
380 });
381 }
382
383 const expectedSignature = signPayload(encodedPayload, signingSecret);
384 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
385 return yield* new SessionCredentialError({
386 message: "Invalid websocket token signature.",
387 });
388 }
389
390 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
391 Effect.mapError(
392 (cause) =>
393 new SessionCredentialError({
394 message: "Invalid websocket token payload.",
395 cause,
396 }),
397 ),
398 );
399
400 const now = yield* Clock.currentTimeMillis;
401 if (claims.exp <= now) {
402 return yield* new SessionCredentialError({
403 message: "Websocket token expired.",
404 });
405 }
406
407 const row = yield* authSessions.getById({ sessionId: claims.sid });
408 if (Option.isNone(row)) {
409 return yield* new SessionCredentialError({
410 message: "Unknown websocket session.",
411 });
412 }
413 if (row.value.expiresAt.epochMilliseconds <= now) {
414 return yield* new SessionCredentialError({
415 message: "Websocket session expired.",
416 });
417 }
418 if (row.value.revokedAt !== null) {
419 return yield* new SessionCredentialError({
420 message: "Websocket session revoked.",
421 });
422 }
423
424 return {
425 sessionId: row.value.sessionId,
426 token,
427 method: row.value.method,
428 client: toClientMetadata(row.value.client),
429 expiresAt: row.value.expiresAt,
430 subject: row.value.subject,
431 role: row.value.role,
432 } satisfies VerifiedSession;
433 }).pipe(
434 Effect.mapError((cause) =>
435 cause instanceof SessionCredentialError
436 ? cause
437 : new SessionCredentialError({
438 message: "Failed to verify websocket token.",
439 cause,
440 }),
441 ),
442 );
443
444 const listActive: SessionCredentialServiceShape["listActive"] = () =>
445 Effect.gen(function* () {
446 const now = yield* DateTime.now;
447 const connectedSessions = yield* Ref.get(connectedSessionsRef);
448 const rows = yield* authSessions.listActive({ now });
449
450 return rows.map((row) =>
451 toAuthClientSession({
452 sessionId: row.sessionId,
453 subject: row.subject,
454 role: row.role,
455 method: row.method,
456 client: toClientMetadata(row.client),
457 issuedAt: row.issuedAt,
458 expiresAt: row.expiresAt,
459 lastConnectedAt: row.lastConnectedAt,
460 connected: connectedSessions.has(row.sessionId),
461 }),
462 );
463 }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions.")));
464
465 const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) =>
466 Effect.gen(function* () {
467 const revokedAt = yield* DateTime.now;
468 const revoked = yield* authSessions.revoke({
469 sessionId,
470 revokedAt,
471 });
472 if (revoked) {
473 yield* Ref.update(connectedSessionsRef, (current) => {
474 const next = new Map(current);
475 next.delete(sessionId);
476 return next;
477 });
478 yield* emitRemoved(sessionId);
479 }
480 return revoked;
481 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session.")));
482
483 const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) =>
484 Effect.gen(function* () {
485 const revokedAt = yield* DateTime.now;
486 const revokedSessionIds = yield* authSessions.revokeAllExcept({
487 currentSessionId: sessionId,
488 revokedAt,
489 });
490 if (revokedSessionIds.length > 0) {
491 yield* Ref.update(connectedSessionsRef, (current) => {
492 const next = new Map(current);
493 for (const revokedSessionId of revokedSessionIds) {
494 next.delete(revokedSessionId);
495 }
496 return next;
497 });
498 yield* Effect.forEach(
499 revokedSessionIds,
500 (revokedSessionId) => emitRemoved(revokedSessionId),
501 {
502 concurrency: "unbounded",
503 discard: true,
504 },
505 );
506 }
507 return revokedSessionIds.length;
508 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));
509
510 return {
511 cookieName,
512 issue,
513 verify,
514 issueWebSocketToken,
515 verifyWebSocketToken,
516 listActive,
517 get streamChanges() {
518 return Stream.fromPubSub(changesPubSub);
519 },
520 revoke,
521 revokeAllExcept,
522 markConnected,
523 markDisconnected,
524 } satisfies SessionCredentialServiceShape;
525});
526
527export const SessionCredentialServiceLive = Layer.effect(
528 SessionCredentialService,
529 makeSessionCredentialService,
530).pipe(Layer.provideMerge(AuthSessionRepositoryLive));
apps/server/src/auth/PairingGrantStore.test.ts created+167
......@@ -0,0 +1,167 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../config.ts";
9import { ServerConfig } from "../config.ts";
10import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
11import * as PairingGrantStore from "./PairingGrantStore.ts";
12
13const makeServerConfigLayer = (
14 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
15) =>
16 Layer.effect(
17 ServerConfig,
18 Effect.gen(function* () {
19 const config = yield* ServerConfig;
20 return {
21 ...config,
22 ...overrides,
23 } satisfies ServerConfigShape;
24 }),
25 ).pipe(
26 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })),
27 );
28
29const makePairingGrantStoreLayer = (
30 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
31) =>
32 PairingGrantStore.layer.pipe(
33 Layer.provide(SqlitePersistenceMemory),
34 Layer.provide(makeServerConfigLayer(overrides)),
35 );
36
37it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
38 it.effect("issues pairing tokens in a short manual-entry format", () =>
39 Effect.gen(function* () {
40 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
41 const issued = yield* bootstrapCredentials.issueOneTimeToken();
42
43 expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/);
44 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
45 );
46
47 it.effect("issues one-time bootstrap tokens that can only be consumed once", () =>
48 Effect.gen(function* () {
49 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
50 const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" });
51 const first = yield* bootstrapCredentials.consume(issued.credential);
52 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
53
54 expect(first.method).toBe("one-time-token");
55 expect(first.scopes).toEqual([
56 "orchestration:read",
57 "orchestration:operate",
58 "terminal:operate",
59 "review:write",
60 "relay:read",
61 ]);
62 expect(first.subject).toBe("one-time-token");
63 expect(first.label).toBe("Julius iPhone");
64 expect(issued.label).toBe("Julius iPhone");
65 expect(second._tag).toBe("BootstrapCredentialInvalidError");
66 expect(second.message).toContain("Unknown bootstrap credential");
67 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
68 );
69
70 it.effect("atomically consumes a one-time token when multiple requests race", () =>
71 Effect.gen(function* () {
72 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
73 const token = yield* bootstrapCredentials.issueOneTimeToken();
74 const results = yield* Effect.all(
75 Array.from({ length: 8 }, () =>
76 Effect.result(bootstrapCredentials.consume(token.credential)),
77 ),
78 {
79 concurrency: "unbounded",
80 },
81 );
82
83 const successes = results.filter((result) => result._tag === "Success");
84 const failures = results.filter((result) => result._tag === "Failure");
85
86 expect(successes).toHaveLength(1);
87 expect(failures).toHaveLength(7);
88 for (const failure of failures) {
89 expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError");
90 expect(failure.failure.message).toContain("Unknown bootstrap credential");
91 }
92 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
93 );
94
95 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
96 Effect.gen(function* () {
97 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
98 const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
99 const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
100
101 expect(first.method).toBe("desktop-bootstrap");
102 expect(first.scopes).toEqual([
103 "orchestration:read",
104 "orchestration:operate",
105 "terminal:operate",
106 "review:write",
107 "relay:read",
108 "access:read",
109 "access:write",
110 "relay:write",
111 ]);
112 expect(first.subject).toBe("desktop-bootstrap");
113 expect(second._tag).toBe("BootstrapCredentialInvalidError");
114 }).pipe(
115 Effect.provide(
116 makePairingGrantStoreLayer({
117 desktopBootstrapToken: "desktop-bootstrap-token",
118 }),
119 ),
120 ),
121 );
122
123 it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
124 Effect.gen(function* () {
125 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
126
127 yield* TestClock.adjust(Duration.minutes(6));
128 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
129
130 expect(expired._tag).toBe("BootstrapCredentialInvalidError");
131 expect(expired.message).toContain("Bootstrap credential expired");
132 }).pipe(
133 Effect.provide(
134 Layer.merge(
135 makePairingGrantStoreLayer({
136 desktopBootstrapToken: "desktop-bootstrap-token",
137 }),
138 TestClock.layer(),
139 ),
140 ),
141 ),
142 );
143
144 it.effect("lists and revokes active pairing links", () =>
145 Effect.gen(function* () {
146 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
147 const first = yield* bootstrapCredentials.issueOneTimeToken();
148 const second = yield* bootstrapCredentials.issueOneTimeToken({
149 scopes: ["orchestration:read", "access:write"],
150 });
151
152 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
153 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
154 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
155
156 const revoked = yield* bootstrapCredentials.revoke(first.id);
157 const activeAfterRevoke = yield* bootstrapCredentials.listActive();
158 const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential));
159
160 expect(revoked).toBe(true);
161 expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
162 expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
163 expect(revokedConsume.message).toContain("no longer available");
164 expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError");
165 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
166 );
167});
apps/server/src/auth/PairingGrantStore.ts created+386
......@@ -0,0 +1,386 @@
1import {
2 AuthAdministrativeScopes,
3 AuthStandardClientScopes,
4 type AuthEnvironmentScope,
5 type AuthPairingLink,
6 type ServerAuthBootstrapMethod,
7} from "@t3tools/contracts";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";
11import * as DateTime from "effect/DateTime";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as PubSub from "effect/PubSub";
16import * as Ref from "effect/Ref";
17import * as Stream from "effect/Stream";
18import * as Option from "effect/Option";
19
20import { ServerConfig } from "../config.ts";
21import { AuthPairingLinkRepositoryLive } from "../persistence/Layers/AuthPairingLinks.ts";
22import { AuthPairingLinkRepository } from "../persistence/Services/AuthPairingLinks.ts";
23
24export interface BootstrapGrant {
25 readonly method: ServerAuthBootstrapMethod;
26 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
27 readonly subject: string;
28 readonly label?: string;
29 readonly expiresAt: DateTime.DateTime;
30}
31
32export class BootstrapCredentialInvalidError extends Data.TaggedError(
33 "BootstrapCredentialInvalidError",
34)<{
35 readonly message: string;
36}> {}
37
38export class BootstrapCredentialInternalError extends Data.TaggedError(
39 "BootstrapCredentialInternalError",
40)<{
41 readonly message: string;
42 readonly cause?: unknown;
43}> {}
44
45export type BootstrapCredentialError =
46 | BootstrapCredentialInvalidError
47 | BootstrapCredentialInternalError;
48
49export interface IssuedBootstrapCredential {
50 readonly id: string;
51 readonly credential: string;
52 readonly label?: string;
53 readonly expiresAt: DateTime.Utc;
54}
55
56export type BootstrapCredentialChange =
57 | {
58 readonly type: "pairingLinkUpserted";
59 readonly pairingLink: AuthPairingLink;
60 }
61 | {
62 readonly type: "pairingLinkRemoved";
63 readonly id: string;
64 };
65
66export interface PairingGrantStoreShape {
67 readonly issueOneTimeToken: (input?: {
68 readonly ttl?: Duration.Duration;
69 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
70 readonly subject?: string;
71 readonly label?: string;
72 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
73 readonly listActive: () => Effect.Effect<
74 ReadonlyArray<AuthPairingLink>,
75 BootstrapCredentialInternalError
76 >;
77 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
78 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
79 readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
80}
81
82export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(
83 "t3/auth/PairingGrantStore",
84) {}
85
86interface StoredBootstrapGrant extends BootstrapGrant {
87 readonly remainingUses: number | "unbounded";
88}
89
90type ConsumeResult =
91 | {
92 readonly _tag: "error";
93 readonly reason: "not-found" | "expired";
94 readonly error: BootstrapCredentialError;
95 }
96 | {
97 readonly _tag: "success";
98 readonly grant: BootstrapGrant;
99 };
100
101const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
102const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
103const PAIRING_TOKEN_LENGTH = 12;
104const PAIRING_TOKEN_REJECTION_LIMIT =
105 Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length;
106
107const invalidBootstrapCredentialError = (message: string) =>
108 new BootstrapCredentialInvalidError({
109 message,
110 });
111
112const internalBootstrapCredentialError = (message: string, cause: unknown) =>
113 new BootstrapCredentialInternalError({
114 message,
115 cause,
116 });
117
118export const make = Effect.fn("makePairingGrantStore")(function* () {
119 const crypto = yield* Crypto.Crypto;
120 const config = yield* ServerConfig;
121 const pairingLinks = yield* AuthPairingLinkRepository;
122 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
123 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
124 const generatePairingToken = Effect.gen(function* () {
125 let credential = "";
126 while (credential.length < PAIRING_TOKEN_LENGTH) {
127 const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH);
128 for (const byte of bytes) {
129 if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {
130 continue;
131 }
132 credential += PAIRING_TOKEN_ALPHABET[byte % PAIRING_TOKEN_ALPHABET.length]!;
133 if (credential.length === PAIRING_TOKEN_LENGTH) {
134 return credential;
135 }
136 }
137 }
138 return credential;
139 });
140
141 const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
142 Ref.update(seededGrantsRef, (current) => {
143 const next = new Map(current);
144 next.set(credential, grant);
145 return next;
146 });
147
148 const emitUpsert = (pairingLink: AuthPairingLink) =>
149 PubSub.publish(changesPubSub, {
150 type: "pairingLinkUpserted",
151 pairingLink,
152 }).pipe(Effect.asVoid);
153
154 const emitRemoved = (id: string) =>
155 PubSub.publish(changesPubSub, {
156 type: "pairingLinkRemoved",
157 id,
158 }).pipe(Effect.asVoid);
159
160 if (config.desktopBootstrapToken) {
161 const now = yield* DateTime.now;
162 yield* seedGrant(config.desktopBootstrapToken, {
163 method: "desktop-bootstrap",
164 scopes: AuthAdministrativeScopes,
165 subject: "desktop-bootstrap",
166 expiresAt: DateTime.add(now, {
167 milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES),
168 }),
169 remainingUses: 1,
170 });
171 }
172
173 const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
174 internalBootstrapCredentialError(message, cause);
175
176 const listActive: PairingGrantStoreShape["listActive"] = Effect.fn(
177 "PairingGrantStore.listActive",
178 )(
179 function* () {
180 const now = yield* DateTime.now;
181 const rows = yield* pairingLinks.listActive({ now });
182
183 return rows.map((row) =>
184 row.label
185 ? ({
186 id: row.id,
187 credential: row.credential,
188 scopes: row.scopes,
189 subject: row.subject,
190 label: row.label,
191 createdAt: row.createdAt,
192 expiresAt: row.expiresAt,
193 } satisfies AuthPairingLink)
194 : ({
195 id: row.id,
196 credential: row.credential,
197 scopes: row.scopes,
198 subject: row.subject,
199 createdAt: row.createdAt,
200 expiresAt: row.expiresAt,
201 } satisfies AuthPairingLink),
202 );
203 },
204 Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")),
205 );
206
207 const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")(
208 function* (id) {
209 const revokedAt = yield* DateTime.now;
210 const revoked = yield* pairingLinks.revoke({
211 id,
212 revokedAt,
213 });
214 if (revoked) {
215 yield* emitRemoved(id);
216 }
217 return revoked;
218 },
219 Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")),
220 );
221
222 const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn(
223 "PairingGrantStore.issueOneTimeToken",
224 )(
225 function* (input) {
226 const id = yield* crypto.randomUUIDv4;
227 const credential = yield* generatePairingToken;
228 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
229 const now = yield* DateTime.now;
230 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
231 const issued: IssuedBootstrapCredential = {
232 id,
233 credential,
234 ...(input?.label ? { label: input.label } : {}),
235 expiresAt,
236 };
237 yield* pairingLinks.create({
238 id,
239 credential,
240 method: "one-time-token",
241 scopes: input?.scopes ?? AuthStandardClientScopes,
242 subject: input?.subject ?? "one-time-token",
243 label: input?.label ?? null,
244 createdAt: now,
245 expiresAt: expiresAt,
246 });
247 yield* emitUpsert({
248 id,
249 credential,
250 scopes: input?.scopes ?? AuthStandardClientScopes,
251 subject: input?.subject ?? "one-time-token",
252 ...(input?.label ? { label: input.label } : {}),
253 createdAt: now,
254 expiresAt,
255 });
256 return issued;
257 },
258 Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")),
259 );
260
261 const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(
262 function* (credential) {
263 const now = yield* DateTime.now;
264 const seededResult: ConsumeResult = yield* Ref.modify(
265 seededGrantsRef,
266 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
267 const grant = current.get(credential);
268 if (!grant) {
269 return [
270 {
271 _tag: "error",
272 reason: "not-found",
273 error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
274 },
275 current,
276 ];
277 }
278
279 const next = new Map(current);
280 if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
281 next.delete(credential);
282 return [
283 {
284 _tag: "error",
285 reason: "expired",
286 error: invalidBootstrapCredentialError("Bootstrap credential expired."),
287 },
288 next,
289 ];
290 }
291
292 const remainingUses = grant.remainingUses;
293 if (typeof remainingUses === "number") {
294 if (remainingUses <= 1) {
295 next.delete(credential);
296 } else {
297 next.set(credential, {
298 ...grant,
299 remainingUses: remainingUses - 1,
300 });
301 }
302 }
303
304 return [
305 {
306 _tag: "success",
307 grant: {
308 method: grant.method,
309 scopes: grant.scopes,
310 subject: grant.subject,
311 ...(grant.label ? { label: grant.label } : {}),
312 expiresAt: grant.expiresAt,
313 } satisfies BootstrapGrant,
314 },
315 next,
316 ];
317 },
318 );
319
320 if (seededResult._tag === "success") {
321 return seededResult.grant;
322 }
323 if (seededResult.reason !== "not-found") {
324 return yield* seededResult.error;
325 }
326
327 const consumed = yield* pairingLinks.consumeAvailable({
328 credential,
329 consumedAt: now,
330 now,
331 });
332
333 if (Option.isSome(consumed)) {
334 yield* emitRemoved(consumed.value.id);
335 return {
336 method: consumed.value.method,
337 scopes: consumed.value.scopes,
338 subject: consumed.value.subject,
339 ...(consumed.value.label ? { label: consumed.value.label } : {}),
340 expiresAt: consumed.value.expiresAt,
341 } satisfies BootstrapGrant;
342 }
343
344 const matching = yield* pairingLinks.getByCredential({ credential });
345 if (Option.isNone(matching)) {
346 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
347 }
348
349 if (matching.value.revokedAt !== null) {
350 return yield* invalidBootstrapCredentialError(
351 "Bootstrap credential is no longer available.",
352 );
353 }
354
355 if (matching.value.consumedAt !== null) {
356 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
357 }
358
359 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
360 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
361 }
362
363 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
364 },
365 Effect.mapError((cause) =>
366 cause._tag === "BootstrapCredentialInvalidError" ||
367 cause._tag === "BootstrapCredentialInternalError"
368 ? cause
369 : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
370 ),
371 );
372
373 return {
374 issueOneTimeToken,
375 listActive,
376 get streamChanges() {
377 return Stream.fromPubSub(changesPubSub);
378 },
379 revoke,
380 consume,
381 } satisfies PairingGrantStoreShape;
382});
383
384export const layer = Layer.effect(PairingGrantStore, make()).pipe(
385 Layer.provideMerge(AuthPairingLinkRepositoryLive),
386);
apps/server/src/auth/ServerSecretStore.test.ts created+267
......@@ -0,0 +1,267 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Ref from "effect/Ref";
9import * as PlatformError from "effect/PlatformError";
10
11import { ServerConfig } from "../config.ts";
12import * as ServerSecretStore from "./ServerSecretStore.ts";
13
14const makeServerConfigLayer = () =>
15 ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" });
16
17const makeServerSecretStoreLayer = () =>
18 Layer.provide(ServerSecretStore.layer, makeServerConfigLayer());
19
20const PermissionDeniedFileSystemLayer = Layer.effect(
21 FileSystem.FileSystem,
22 Effect.gen(function* () {
23 const fileSystem = yield* FileSystem.FileSystem;
24
25 return {
26 ...fileSystem,
27 readFile: (path) =>
28 Effect.fail(
29 PlatformError.systemError({
30 _tag: "PermissionDenied",
31 module: "FileSystem",
32 method: "readFile",
33 pathOrDescriptor: path,
34 description: "Permission denied while reading secret file.",
35 }),
36 ),
37 } satisfies FileSystem.FileSystem;
38 }),
39).pipe(Layer.provide(NodeServices.layer));
40
41const makePermissionDeniedSecretStoreLayer = () =>
42 ServerSecretStore.layer.pipe(
43 Layer.provide(makeServerConfigLayer()),
44 Layer.provideMerge(PermissionDeniedFileSystemLayer),
45 );
46
47const RenameFailureFileSystemLayer = Layer.effect(
48 FileSystem.FileSystem,
49 Effect.gen(function* () {
50 const fileSystem = yield* FileSystem.FileSystem;
51
52 return {
53 ...fileSystem,
54 rename: (from, to) =>
55 Effect.fail(
56 PlatformError.systemError({
57 _tag: "PermissionDenied",
58 module: "FileSystem",
59 method: "rename",
60 pathOrDescriptor: `${String(from)} -> ${String(to)}`,
61 description: "Permission denied while persisting secret file.",
62 }),
63 ),
64 } satisfies FileSystem.FileSystem;
65 }),
66).pipe(Layer.provide(NodeServices.layer));
67
68const makeRenameFailureSecretStoreLayer = () =>
69 ServerSecretStore.layer.pipe(
70 Layer.provide(makeServerConfigLayer()),
71 Layer.provideMerge(RenameFailureFileSystemLayer),
72 );
73
74const RemoveFailureFileSystemLayer = Layer.effect(
75 FileSystem.FileSystem,
76 Effect.gen(function* () {
77 const fileSystem = yield* FileSystem.FileSystem;
78
79 return {
80 ...fileSystem,
81 remove: (path, options) =>
82 Effect.fail(
83 PlatformError.systemError({
84 _tag: "PermissionDenied",
85 module: "FileSystem",
86 method: "remove",
87 pathOrDescriptor: String(path),
88 description: `Permission denied while removing secret file.${options ? " options-set" : ""}`,
89 }),
90 ),
91 } satisfies FileSystem.FileSystem;
92 }),
93).pipe(Layer.provide(NodeServices.layer));
94
95const makeRemoveFailureSecretStoreLayer = () =>
96 ServerSecretStore.layer.pipe(
97 Layer.provide(makeServerConfigLayer()),
98 Layer.provideMerge(RemoveFailureFileSystemLayer),
99 );
100
101const ConcurrentReadMissFileSystemLayer = Layer.effect(
102 FileSystem.FileSystem,
103 Effect.gen(function* () {
104 const fileSystem = yield* FileSystem.FileSystem;
105 const readCountRef = yield* Ref.make(0);
106 const readBarrier = yield* Deferred.make<void>();
107
108 return {
109 ...fileSystem,
110 readFile: (path) =>
111 String(path).endsWith("/session-signing-key.bin")
112 ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe(
113 Effect.flatMap((count) => {
114 if (count > 2) {
115 return fileSystem.readFile(path);
116 }
117 return Effect.gen(function* () {
118 if (count === 2) {
119 yield* Deferred.succeed(readBarrier, void 0);
120 }
121 yield* Deferred.await(readBarrier);
122 return yield* Effect.failCause(
123 Cause.fail(
124 PlatformError.systemError({
125 _tag: "NotFound",
126 module: "FileSystem",
127 method: "readFile",
128 pathOrDescriptor: String(path),
129 description: "Secret file does not exist yet.",
130 }),
131 ),
132 );
133 });
134 }),
135 )
136 : fileSystem.readFile(path),
137 } satisfies FileSystem.FileSystem;
138 }),
139).pipe(Layer.provide(NodeServices.layer));
140
141const makeConcurrentCreateSecretStoreLayer = () =>
142 ServerSecretStore.layer.pipe(
143 Layer.provide(makeServerConfigLayer()),
144 Layer.provideMerge(ConcurrentReadMissFileSystemLayer),
145 );
146
147it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => {
148 it.effect("returns null when a secret file does not exist", () =>
149 Effect.gen(function* () {
150 const secretStore = yield* ServerSecretStore.ServerSecretStore;
151
152 const secret = yield* secretStore.get("missing-secret");
153
154 expect(secret).toBeNull();
155 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
156 );
157
158 it.effect("reuses an existing secret instead of regenerating it", () =>
159 Effect.gen(function* () {
160 const secretStore = yield* ServerSecretStore.ServerSecretStore;
161
162 const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
163 const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
164
165 expect(Array.from(second)).toEqual(Array.from(first));
166 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
167 );
168
169 it.effect("returns the persisted secret when concurrent creators race", () =>
170 Effect.gen(function* () {
171 const secretStore = yield* ServerSecretStore.ServerSecretStore;
172
173 const [first, second] = yield* Effect.all(
174 [
175 secretStore.getOrCreateRandom("session-signing-key", 32),
176 secretStore.getOrCreateRandom("session-signing-key", 32),
177 ],
178 { concurrency: "unbounded" },
179 );
180 const persisted = yield* secretStore.get("session-signing-key");
181
182 expect(persisted).not.toBeNull();
183 expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array()));
184 expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array()));
185 }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())),
186 );
187
188 it.effect("uses restrictive permissions for the secret directory and files", () =>
189 Effect.gen(function* () {
190 const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = [];
191 const recordingFileSystemLayer = Layer.effect(
192 FileSystem.FileSystem,
193 Effect.gen(function* () {
194 const fileSystem = yield* FileSystem.FileSystem;
195
196 return {
197 ...fileSystem,
198 makeDirectory: () => Effect.void,
199 writeFile: () => Effect.void,
200 rename: () => Effect.void,
201 chmod: (path, mode) =>
202 Effect.sync(() => {
203 chmodCalls.push({ path: String(path), mode });
204 }),
205 } satisfies FileSystem.FileSystem;
206 }),
207 ).pipe(Layer.provide(NodeServices.layer));
208
209 const secretStore = yield* Effect.service(ServerSecretStore.ServerSecretStore).pipe(
210 Effect.provide(
211 ServerSecretStore.layer.pipe(
212 Layer.provide(makeServerConfigLayer()),
213 Layer.provideMerge(recordingFileSystemLayer),
214 ),
215 ),
216 );
217
218 yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3]));
219
220 expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe(
221 true,
222 );
223 expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2);
224 }).pipe(Effect.provide(NodeServices.layer)),
225 );
226
227 it.effect("propagates read failures other than missing-file errors", () =>
228 Effect.gen(function* () {
229 const secretStore = yield* ServerSecretStore.ServerSecretStore;
230
231 const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
232
233 expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError);
234 expect(error.message).toContain("Failed to read secret session-signing-key.");
235 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
236 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
237 }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())),
238 );
239
240 it.effect("propagates write failures instead of treating them as success", () =>
241 Effect.gen(function* () {
242 const secretStore = yield* ServerSecretStore.ServerSecretStore;
243
244 const error = yield* Effect.flip(
245 secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
246 );
247
248 expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError);
249 expect(error.message).toContain("Failed to persist secret session-signing-key.");
250 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
251 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
252 }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())),
253 );
254
255 it.effect("propagates remove failures other than missing-file errors", () =>
256 Effect.gen(function* () {
257 const secretStore = yield* ServerSecretStore.ServerSecretStore;
258
259 const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
260
261 expect(error).toBeInstanceOf(ServerSecretStore.SecretStoreError);
262 expect(error.message).toContain("Failed to remove secret session-signing-key.");
263 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
264 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
265 }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())),
266 );
267});
apps/server/src/auth/ServerSecretStore.ts created+193
......@@ -0,0 +1,193 @@
1import * as Context from "effect/Context";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Predicate from "effect/Predicate";
9import * as PlatformError from "effect/PlatformError";
10
11import { ServerConfig } from "../config.ts";
12
13export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
14 readonly message: string;
15 readonly cause?: unknown;
16}> {}
17
18export interface ServerSecretStoreShape {
19 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
20 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
21 readonly getOrCreateRandom: (
22 name: string,
23 bytes: number,
24 ) => Effect.Effect<Uint8Array, SecretStoreError>;
25 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
26}
27
28export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
29 "t3/auth/ServerSecretStore",
30) {}
31
32export const make = Effect.fn("makeServerSecretStore")(function* () {
33 const crypto = yield* Crypto.Crypto;
34 const fileSystem = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const serverConfig = yield* ServerConfig;
37
38 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
39 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
40 Effect.mapError(
41 (cause) =>
42 new SecretStoreError({
43 message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
44 cause,
45 }),
46 ),
47 );
48
49 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
50
51 const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
52 Predicate.isTagged(u, "PlatformError");
53
54 const get: ServerSecretStoreShape["get"] = (name) =>
55 fileSystem.readFile(resolveSecretPath(name)).pipe(
56 Effect.map((bytes) => Uint8Array.from(bytes)),
57 Effect.catch((cause) =>
58 cause.reason._tag === "NotFound"
59 ? Effect.succeed(null)
60 : Effect.fail(
61 new SecretStoreError({
62 message: `Failed to read secret ${name}.`,
63 cause,
64 }),
65 ),
66 ),
67 Effect.withSpan("ServerSecretStore.get"),
68 );
69
70 const set: ServerSecretStoreShape["set"] = (name, value) => {
71 const secretPath = resolveSecretPath(name);
72 return crypto.randomUUIDv4.pipe(
73 Effect.mapError(
74 (cause) =>
75 new SecretStoreError({
76 message: `Failed to create temporary path for secret ${name}.`,
77 cause,
78 }),
79 ),
80 Effect.flatMap((uuid) => {
81 const tempPath = `${secretPath}.${uuid}.tmp`;
82 return Effect.gen(function* () {
83 yield* fileSystem.writeFile(tempPath, value);
84 yield* fileSystem.chmod(tempPath, 0o600);
85 yield* fileSystem.rename(tempPath, secretPath);
86 yield* fileSystem.chmod(secretPath, 0o600);
87 }).pipe(
88 Effect.catch((cause) =>
89 fileSystem.remove(tempPath).pipe(
90 Effect.ignore,
91 Effect.flatMap(() =>
92 Effect.fail(
93 new SecretStoreError({
94 message: `Failed to persist secret ${name}.`,
95 cause,
96 }),
97 ),
98 ),
99 ),
100 ),
101 );
102 }),
103 Effect.withSpan("ServerSecretStore.set"),
104 );
105 };
106
107 const create: ServerSecretStoreShape["set"] = (name, value) => {
108 const secretPath = resolveSecretPath(name);
109 return Effect.scoped(
110 Effect.gen(function* () {
111 const file = yield* fileSystem.open(secretPath, {
112 flag: "wx",
113 mode: 0o600,
114 });
115 yield* file.writeAll(value);
116 yield* file.sync;
117 yield* fileSystem.chmod(secretPath, 0o600);
118 }),
119 ).pipe(
120 Effect.mapError(
121 (cause) =>
122 new SecretStoreError({
123 message: `Failed to persist secret ${name}.`,
124 cause,
125 }),
126 ),
127 );
128 };
129
130 const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
131 get(name).pipe(
132 Effect.flatMap((existing) => {
133 if (existing) {
134 return Effect.succeed(existing);
135 }
136
137 return crypto.randomBytes(bytes).pipe(
138 Effect.mapError(
139 (cause) =>
140 new SecretStoreError({
141 message: `Failed to generate random bytes for secret ${name}.`,
142 cause,
143 }),
144 ),
145 Effect.flatMap((generated) =>
146 create(name, generated).pipe(
147 Effect.as(Uint8Array.from(generated)),
148 Effect.catchTag("SecretStoreError", (error) =>
149 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
150 ? get(name).pipe(
151 Effect.flatMap((created) =>
152 created !== null
153 ? Effect.succeed(created)
154 : Effect.fail(
155 new SecretStoreError({
156 message: `Failed to read secret ${name} after concurrent creation.`,
157 }),
158 ),
159 ),
160 )
161 : Effect.fail(error),
162 ),
163 ),
164 ),
165 );
166 }),
167 Effect.withSpan("ServerSecretStore.getOrCreateRandom"),
168 );
169
170 const remove: ServerSecretStoreShape["remove"] = (name) =>
171 fileSystem.remove(resolveSecretPath(name)).pipe(
172 Effect.catch((cause) =>
173 cause.reason._tag === "NotFound"
174 ? Effect.void
175 : Effect.fail(
176 new SecretStoreError({
177 message: `Failed to remove secret ${name}.`,
178 cause,
179 }),
180 ),
181 ),
182 Effect.withSpan("ServerSecretStore.remove"),
183 );
184
185 return {
186 get,
187 set,
188 getOrCreateRandom,
189 remove,
190 } satisfies ServerSecretStoreShape;
191});
192
193export const layer = Layer.effect(ServerSecretStore, make());
apps/server/src/auth/Services/AuthControlPlane.ts deleted-73
......@@ -1,73 +0,0 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthPairingLink,
5 AuthSessionId,
6} from "@t3tools/contracts";
7import * as Data from "effect/Data";
8import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Context from "effect/Context";
12import type { SessionRole } from "./SessionCredentialService.ts";
13
14export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
15
16export interface IssuedPairingLink {
17 readonly id: string;
18 readonly credential: string;
19 readonly role: SessionRole;
20 readonly subject: string;
21 readonly label?: string;
22 readonly createdAt: DateTime.Utc;
23 readonly expiresAt: DateTime.Utc;
24}
25
26export interface IssuedBearerSession {
27 readonly sessionId: AuthSessionId;
28 readonly token: string;
29 readonly method: "bearer-session-token";
30 readonly role: SessionRole;
31 readonly subject: string;
32 readonly client: AuthClientMetadata;
33 readonly expiresAt: DateTime.Utc;
34}
35
36export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{
37 readonly message: string;
38 readonly cause?: unknown;
39}> {}
40
41export interface AuthControlPlaneShape {
42 readonly createPairingLink: (input?: {
43 readonly ttl?: Duration.Duration;
44 readonly label?: string;
45 readonly role?: SessionRole;
46 readonly subject?: string;
47 }) => Effect.Effect<IssuedPairingLink, AuthControlPlaneError>;
48 readonly listPairingLinks: (input?: {
49 readonly role?: SessionRole;
50 readonly excludeSubjects?: ReadonlyArray<string>;
51 }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthControlPlaneError>;
52 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthControlPlaneError>;
53 readonly issueSession: (input?: {
54 readonly ttl?: Duration.Duration;
55 readonly subject?: string;
56 readonly role?: SessionRole;
57 readonly label?: string;
58 }) => Effect.Effect<IssuedBearerSession, AuthControlPlaneError>;
59 readonly listSessions: () => Effect.Effect<
60 ReadonlyArray<AuthClientSession>,
61 AuthControlPlaneError
62 >;
63 readonly revokeSession: (
64 sessionId: AuthSessionId,
65 ) => Effect.Effect<boolean, AuthControlPlaneError>;
66 readonly revokeOtherSessionsExcept: (
67 sessionId: AuthSessionId,
68 ) => Effect.Effect<number, AuthControlPlaneError>;
69}
70
71export class AuthControlPlane extends Context.Service<AuthControlPlane, AuthControlPlaneShape>()(
72 "t3/auth/Services/AuthControlPlane",
73) {}
apps/server/src/auth/Services/BootstrapCredentialService.ts deleted-61
......@@ -1,61 +0,0 @@
1import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts";
2import * as Data from "effect/Data";
3import * as DateTime from "effect/DateTime";
4import * as Duration from "effect/Duration";
5import * as Context from "effect/Context";
6import type * as Effect from "effect/Effect";
7import type * as Stream from "effect/Stream";
8
9export type BootstrapCredentialRole = "owner" | "client";
10
11export interface BootstrapGrant {
12 readonly method: ServerAuthBootstrapMethod;
13 readonly role: BootstrapCredentialRole;
14 readonly subject: string;
15 readonly label?: string;
16 readonly expiresAt: DateTime.DateTime;
17}
18
19export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{
20 readonly message: string;
21 readonly status?: 401 | 500;
22 readonly cause?: unknown;
23}> {}
24
25export interface IssuedBootstrapCredential {
26 readonly id: string;
27 readonly credential: string;
28 readonly label?: string;
29 readonly expiresAt: DateTime.Utc;
30}
31
32export type BootstrapCredentialChange =
33 | {
34 readonly type: "pairingLinkUpserted";
35 readonly pairingLink: AuthPairingLink;
36 }
37 | {
38 readonly type: "pairingLinkRemoved";
39 readonly id: string;
40 };
41
42export interface BootstrapCredentialServiceShape {
43 readonly issueOneTimeToken: (input?: {
44 readonly ttl?: Duration.Duration;
45 readonly role?: BootstrapCredentialRole;
46 readonly subject?: string;
47 readonly label?: string;
48 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialError>;
49 readonly listActive: () => Effect.Effect<
50 ReadonlyArray<AuthPairingLink>,
51 BootstrapCredentialError
52 >;
53 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
54 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialError>;
55 readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
56}
57
58export class BootstrapCredentialService extends Context.Service<
59 BootstrapCredentialService,
60 BootstrapCredentialServiceShape
61>()("t3/auth/Services/BootstrapCredentialService") {}
apps/server/src/auth/Services/ServerAuth.ts deleted-86
......@@ -1,86 +0,0 @@
1import type {
2 AuthBearerBootstrapResult,
3 AuthBootstrapResult,
4 AuthClientMetadata,
5 AuthClientSession,
6 AuthCreatePairingCredentialInput,
7 AuthPairingLink,
8 AuthPairingCredentialResult,
9 AuthSessionId,
10 AuthSessionState,
11 ServerAuthDescriptor,
12 ServerAuthSessionMethod,
13 AuthWebSocketTokenResult,
14} from "@t3tools/contracts";
15import * as Data from "effect/Data";
16import * as DateTime from "effect/DateTime";
17import * as Context from "effect/Context";
18import type * as Effect from "effect/Effect";
19import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
20import type { SessionRole } from "./SessionCredentialService.ts";
21
22export interface AuthenticatedSession {
23 readonly sessionId: AuthSessionId;
24 readonly subject: string;
25 readonly method: ServerAuthSessionMethod;
26 readonly role: SessionRole;
27 readonly expiresAt?: DateTime.DateTime;
28}
29
30export class AuthError extends Data.TaggedError("AuthError")<{
31 readonly message: string;
32 readonly status?: 400 | 401 | 403 | 500;
33 readonly cause?: unknown;
34}> {}
35
36export interface ServerAuthShape {
37 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
38 readonly getSessionState: (
39 request: HttpServerRequest.HttpServerRequest,
40 ) => Effect.Effect<AuthSessionState, never>;
41 readonly exchangeBootstrapCredential: (
42 credential: string,
43 requestMetadata: AuthClientMetadata,
44 ) => Effect.Effect<
45 {
46 readonly response: AuthBootstrapResult;
47 readonly sessionToken: string;
48 },
49 AuthError
50 >;
51 readonly exchangeBootstrapCredentialForBearerSession: (
52 credential: string,
53 requestMetadata: AuthClientMetadata,
54 ) => Effect.Effect<AuthBearerBootstrapResult, AuthError>;
55 readonly issuePairingCredential: (
56 input?: AuthCreatePairingCredentialInput & {
57 readonly role?: SessionRole;
58 },
59 ) => Effect.Effect<AuthPairingCredentialResult, AuthError>;
60 readonly listPairingLinks: () => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthError>;
61 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthError>;
62 readonly listClientSessions: (
63 currentSessionId: AuthSessionId,
64 ) => Effect.Effect<ReadonlyArray<AuthClientSession>, AuthError>;
65 readonly revokeClientSession: (
66 currentSessionId: AuthSessionId,
67 targetSessionId: AuthSessionId,
68 ) => Effect.Effect<boolean, AuthError>;
69 readonly revokeOtherClientSessions: (
70 currentSessionId: AuthSessionId,
71 ) => Effect.Effect<number, AuthError>;
72 readonly authenticateHttpRequest: (
73 request: HttpServerRequest.HttpServerRequest,
74 ) => Effect.Effect<AuthenticatedSession, AuthError>;
75 readonly authenticateWebSocketUpgrade: (
76 request: HttpServerRequest.HttpServerRequest,
77 ) => Effect.Effect<AuthenticatedSession, AuthError>;
78 readonly issueWebSocketToken: (
79 session: AuthenticatedSession,
80 ) => Effect.Effect<AuthWebSocketTokenResult, AuthError>;
81 readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect<string, AuthError>;
82}
83
84export class ServerAuth extends Context.Service<ServerAuth, ServerAuthShape>()(
85 "t3/auth/Services/ServerAuth",
86) {}
apps/server/src/auth/Services/ServerAuthPolicy.ts deleted-11
......@@ -1,11 +0,0 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export interface ServerAuthPolicyShape {
6 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
7}
8
9export class ServerAuthPolicy extends Context.Service<ServerAuthPolicy, ServerAuthPolicyShape>()(
10 "t3/auth/Services/ServerAuthPolicy",
11) {}
apps/server/src/auth/Services/ServerSecretStore.ts deleted-22
......@@ -1,22 +0,0 @@
1import * as Data from "effect/Data";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
6 readonly message: string;
7 readonly cause?: unknown;
8}> {}
9
10export interface ServerSecretStoreShape {
11 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
12 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
13 readonly getOrCreateRandom: (
14 name: string,
15 bytes: number,
16 ) => Effect.Effect<Uint8Array, SecretStoreError>;
17 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
18}
19
20export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
21 "t3/auth/Services/ServerSecretStore",
22) {}
apps/server/src/auth/Services/SessionCredentialService.ts deleted-91
......@@ -1,91 +0,0 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthSessionId,
5 ServerAuthSessionMethod,
6} from "@t3tools/contracts";
7import * as Data from "effect/Data";
8import * as DateTime from "effect/DateTime";
9import * as Duration from "effect/Duration";
10import * as Context from "effect/Context";
11import type * as Effect from "effect/Effect";
12import type * as Stream from "effect/Stream";
13
14export type SessionRole = "owner" | "client";
15
16export interface IssuedSession {
17 readonly sessionId: AuthSessionId;
18 readonly token: string;
19 readonly method: ServerAuthSessionMethod;
20 readonly client: AuthClientMetadata;
21 readonly expiresAt: DateTime.DateTime;
22 readonly role: SessionRole;
23}
24
25export interface VerifiedSession {
26 readonly sessionId: AuthSessionId;
27 readonly token: string;
28 readonly method: ServerAuthSessionMethod;
29 readonly client: AuthClientMetadata;
30 readonly expiresAt?: DateTime.DateTime;
31 readonly subject: string;
32 readonly role: SessionRole;
33}
34
35export type SessionCredentialChange =
36 | {
37 readonly type: "clientUpserted";
38 readonly clientSession: AuthClientSession;
39 }
40 | {
41 readonly type: "clientRemoved";
42 readonly sessionId: AuthSessionId;
43 };
44
45export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{
46 readonly message: string;
47 readonly cause?: unknown;
48}> {}
49
50export interface SessionCredentialServiceShape {
51 readonly cookieName: string;
52 readonly issue: (input?: {
53 readonly ttl?: Duration.Duration;
54 readonly subject?: string;
55 readonly method?: ServerAuthSessionMethod;
56 readonly role?: SessionRole;
57 readonly client?: AuthClientMetadata;
58 }) => Effect.Effect<IssuedSession, SessionCredentialError>;
59 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
60 readonly issueWebSocketToken: (
61 sessionId: AuthSessionId,
62 input?: {
63 readonly ttl?: Duration.Duration;
64 },
65 ) => Effect.Effect<
66 {
67 readonly token: string;
68 readonly expiresAt: DateTime.DateTime;
69 },
70 SessionCredentialError
71 >;
72 readonly verifyWebSocketToken: (
73 token: string,
74 ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
75 readonly listActive: () => Effect.Effect<
76 ReadonlyArray<AuthClientSession>,
77 SessionCredentialError
78 >;
79 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
80 readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>;
81 readonly revokeAllExcept: (
82 sessionId: AuthSessionId,
83 ) => Effect.Effect<number, SessionCredentialError>;
84 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
85 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
86}
87
88export class SessionCredentialService extends Context.Service<
89 SessionCredentialService,
90 SessionCredentialServiceShape
91>()("t3/auth/Services/SessionCredentialService") {}
apps/server/src/auth/SessionStore.test.ts created+243
......@@ -0,0 +1,243 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as TestClock from "effect/testing/TestClock";
7
8import type { ServerConfigShape } from "../config.ts";
9import { ServerConfig } from "../config.ts";
10import { PersistenceSqlError } from "../persistence/Errors.ts";
11import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
12import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts";
13import * as SessionStore from "./SessionStore.ts";
14import * as ServerSecretStore from "./ServerSecretStore.ts";
15
16const makeServerConfigLayer = (
17 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
18) =>
19 Layer.effect(
20 ServerConfig,
21 Effect.gen(function* () {
22 const config = yield* ServerConfig;
23 return {
24 ...config,
25 ...overrides,
26 } satisfies ServerConfigShape;
27 }),
28 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
29
30const makeSessionStoreLayer = (
31 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
32) =>
33 SessionStore.layer.pipe(
34 Layer.provide(SqlitePersistenceMemory),
35 Layer.provide(ServerSecretStore.layer),
36 Layer.provide(makeServerConfigLayer(overrides)),
37 );
38
39const repositoryFailure = new PersistenceSqlError({
40 operation: "AuthSessionRepository.getById:query",
41 detail: "sqlite is unavailable",
42});
43
44const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessionRepository, {
45 create: () => Effect.void,
46 getById: () => Effect.fail(repositoryFailure),
47 listActive: () => Effect.succeed([]),
48 revoke: () => Effect.succeed(false),
49 revokeAllExcept: () => Effect.succeed([]),
50 setLastConnectedAt: () => Effect.void,
51});
52
53const failingSessionLookupCredentialLayer = Layer.effect(
54 SessionStore.SessionStore,
55 SessionStore.make(),
56).pipe(
57 Layer.provide(failingSessionLookupRepositoryLayer),
58 Layer.provide(ServerSecretStore.layer),
59 Layer.provide(SqlitePersistenceMemory),
60 Layer.provide(makeServerConfigLayer()),
61);
62
63it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
64 it.effect("issues and verifies signed browser session tokens", () =>
65 Effect.gen(function* () {
66 const sessions = yield* SessionStore.SessionStore;
67 const issued = yield* sessions.issue({
68 subject: "desktop-bootstrap",
69 scopes: ["orchestration:read", "access:write"],
70 client: {
71 label: "Desktop app",
72 deviceType: "desktop",
73 os: "macOS",
74 browser: "Electron",
75 ipAddress: "127.0.0.1",
76 },
77 });
78 const verified = yield* sessions.verify(issued.token);
79
80 expect(verified.method).toBe("browser-session-cookie");
81 expect(verified.subject).toBe("desktop-bootstrap");
82 expect(verified.scopes).toEqual(["orchestration:read", "access:write"]);
83 expect(verified.client.label).toBe("Desktop app");
84 expect(verified.client.browser).toBe("Electron");
85 expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
86 }).pipe(Effect.provide(makeSessionStoreLayer())),
87 );
88 it.effect("rejects malformed session tokens", () =>
89 Effect.gen(function* () {
90 const sessions = yield* SessionStore.SessionStore;
91 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
92
93 expect(error._tag).toBe("SessionCredentialInvalidError");
94 expect(error.message).toContain("Malformed session token");
95 }).pipe(Effect.provide(makeSessionStoreLayer())),
96 );
97 it.effect("preserves repository failures while verifying session and websocket credentials", () =>
98 Effect.gen(function* () {
99 const sessions = yield* SessionStore.SessionStore;
100 const issued = yield* sessions.issue({
101 method: "bearer-access-token",
102 subject: "repository-failure",
103 });
104 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
105
106 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
107 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
108
109 expect(sessionError._tag).toBe("SessionCredentialInternalError");
110 expect(websocketError._tag).toBe("SessionCredentialInternalError");
111 expect(sessionError.cause).toBe(repositoryFailure);
112 expect(websocketError.cause).toBe(repositoryFailure);
113 }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),
114 );
115 it.effect("verifies session tokens against the Effect clock", () =>
116 Effect.gen(function* () {
117 const sessions = yield* SessionStore.SessionStore;
118 const issued = yield* sessions.issue({
119 method: "bearer-access-token",
120 subject: "test-clock",
121 });
122 const verified = yield* sessions.verify(issued.token);
123
124 expect(verified.method).toBe("bearer-access-token");
125 expect(verified.subject).toBe("test-clock");
126 expect(verified.scopes).toEqual([
127 "orchestration:read",
128 "orchestration:operate",
129 "terminal:operate",
130 "review:write",
131 "relay:read",
132 ]);
133 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
134 );
135
136 it.effect("rejects websocket tokens once the parent session has expired", () =>
137 Effect.gen(function* () {
138 const sessions = yield* SessionStore.SessionStore;
139 const issued = yield* sessions.issue({
140 method: "bearer-access-token",
141 subject: "short-lived",
142 ttl: Duration.seconds(1),
143 });
144 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
145
146 yield* TestClock.adjust(Duration.seconds(2));
147
148 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
149 expect(error.message).toContain("expired");
150 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
151 );
152
153 it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
154 Effect.gen(function* () {
155 const sessions = yield* SessionStore.SessionStore;
156 const administrative = yield* sessions.issue({
157 subject: "desktop-bootstrap",
158 scopes: ["orchestration:read", "access:write"],
159 client: {
160 label: "Desktop app",
161 deviceType: "desktop",
162 os: "macOS",
163 browser: "Electron",
164 },
165 });
166 const client = yield* sessions.issue({
167 subject: "one-time-token",
168 scopes: ["orchestration:read"],
169 client: {
170 label: "Julius iPhone",
171 deviceType: "mobile",
172 os: "iOS",
173 browser: "Safari",
174 ipAddress: "192.168.1.88",
175 },
176 });
177
178 yield* sessions.markConnected(client.sessionId);
179 const beforeRevoke = yield* sessions.listActive();
180 const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);
181 const afterRevoke = yield* sessions.listActive();
182 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
183
184 expect(beforeRevoke).toHaveLength(2);
185 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
186 true,
187 );
188 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
189 "Julius iPhone",
190 );
191 expect(
192 beforeRevoke.find((entry) => entry.sessionId === administrative.sessionId)?.client
193 .deviceType,
194 ).toBe("desktop");
195 expect(revokedCount).toBe(1);
196 expect(afterRevoke).toHaveLength(1);
197 expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId);
198 expect(revokedClient.message).toContain("revoked");
199 }).pipe(Effect.provide(makeSessionStoreLayer())),
200 );
201
202 it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>
203 Effect.gen(function* () {
204 const sessions = yield* SessionStore.SessionStore;
205 const issued = yield* sessions.issue({
206 subject: "reconnect-test",
207 method: "bearer-access-token",
208 });
209
210 const beforeConnect = yield* sessions.listActive();
211 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
212
213 yield* TestClock.adjust(Duration.seconds(1));
214 yield* sessions.markConnected(issued.sessionId);
215 const firstConnect = yield* sessions.listActive();
216 const firstConnectedAt = firstConnect[0]?.lastConnectedAt;
217
218 expect(firstConnect[0]?.connected).toBe(true);
219 expect(firstConnectedAt).not.toBeNull();
220
221 yield* TestClock.adjust(Duration.seconds(1));
222 yield* sessions.markConnected(issued.sessionId);
223 const stillConnected = yield* sessions.listActive();
224
225 expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
226
227 yield* sessions.markDisconnected(issued.sessionId);
228 yield* sessions.markDisconnected(issued.sessionId);
229 const afterDisconnect = yield* sessions.listActive();
230
231 expect(afterDisconnect[0]?.connected).toBe(false);
232 expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
233
234 yield* TestClock.adjust(Duration.seconds(1));
235 yield* sessions.markConnected(issued.sessionId);
236 const afterReconnect = yield* sessions.listActive();
237
238 expect(afterReconnect[0]?.connected).toBe(true);
239 expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();
240 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
241 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
242 );
243});
apps/server/src/auth/SessionStore.ts created+637
......@@ -0,0 +1,637 @@
1import {
2 AuthSessionId,
3 AuthStandardClientScopes,
4 AuthEnvironmentScopes,
5 type AuthClientMetadata,
6 type AuthClientSession,
7 type AuthEnvironmentScope,
8 type ServerAuthSessionMethod,
9} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as Data from "effect/Data";
14import * as DateTime from "effect/DateTime";
15import * as Duration from "effect/Duration";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as PubSub from "effect/PubSub";
19import * as Ref from "effect/Ref";
20import * as Schema from "effect/Schema";
21import * as Stream from "effect/Stream";
22import * as Option from "effect/Option";
23
24import { ServerConfig } from "../config.ts";
25import { AuthSessionRepositoryLive } from "../persistence/Layers/AuthSessions.ts";
26import { AuthSessionRepository } from "../persistence/Services/AuthSessions.ts";
27import * as ServerSecretStore from "./ServerSecretStore.ts";
28import {
29 base64UrlDecodeUtf8,
30 base64UrlEncode,
31 resolveSessionCookieName,
32 signPayload,
33 timingSafeEqualBase64Url,
34} from "./utils.ts";
35
36export interface IssuedSession {
37 readonly sessionId: AuthSessionId;
38 readonly token: string;
39 readonly method: ServerAuthSessionMethod;
40 readonly client: AuthClientMetadata;
41 readonly expiresAt: DateTime.DateTime;
42 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43}
44
45export interface VerifiedSession {
46 readonly sessionId: AuthSessionId;
47 readonly token: string;
48 readonly method: ServerAuthSessionMethod;
49 readonly client: AuthClientMetadata;
50 readonly expiresAt?: DateTime.DateTime;
51 readonly subject: string;
52 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
53}
54
55export type SessionCredentialChange =
56 | {
57 readonly type: "clientUpserted";
58 readonly clientSession: AuthClientSession;
59 }
60 | {
61 readonly type: "clientRemoved";
62 readonly sessionId: AuthSessionId;
63 };
64
65export class SessionCredentialInvalidError extends Data.TaggedError(
66 "SessionCredentialInvalidError",
67)<{
68 readonly message: string;
69 readonly cause?: unknown;
70}> {}
71
72export class SessionCredentialInternalError extends Data.TaggedError(
73 "SessionCredentialInternalError",
74)<{
75 readonly message: string;
76 readonly cause?: unknown;
77}> {}
78
79export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError;
80
81export interface SessionStoreShape {
82 readonly cookieName: string;
83 readonly issue: (input?: {
84 readonly ttl?: Duration.Duration;
85 readonly subject?: string;
86 readonly method?: ServerAuthSessionMethod;
87 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
88 readonly client?: AuthClientMetadata;
89 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
90 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
91 readonly issueWebSocketToken: (
92 sessionId: AuthSessionId,
93 input?: {
94 readonly ttl?: Duration.Duration;
95 },
96 ) => Effect.Effect<
97 {
98 readonly token: string;
99 readonly expiresAt: DateTime.DateTime;
100 },
101 SessionCredentialInternalError
102 >;
103 readonly verifyWebSocketToken: (
104 token: string,
105 ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
106 readonly listActive: () => Effect.Effect<
107 ReadonlyArray<AuthClientSession>,
108 SessionCredentialInternalError
109 >;
110 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
111 readonly revoke: (
112 sessionId: AuthSessionId,
113 ) => Effect.Effect<boolean, SessionCredentialInternalError>;
114 readonly revokeAllExcept: (
115 sessionId: AuthSessionId,
116 ) => Effect.Effect<number, SessionCredentialInternalError>;
117 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
118 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
119}
120
121export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()(
122 "t3/auth/SessionStore",
123) {}
124
125const SIGNING_SECRET_NAME = "server-signing-key";
126const DEFAULT_SESSION_TTL = Duration.days(30);
127const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
128
129const SessionClaims = Schema.Struct({
130 v: Schema.Literal(1),
131 kind: Schema.Literal("session"),
132 sid: AuthSessionId,
133 sub: Schema.String,
134 scopes: AuthEnvironmentScopes,
135 method: Schema.Literals(["browser-session-cookie", "bearer-access-token"]),
136 iat: Schema.Number,
137 exp: Schema.Number,
138});
139type SessionClaims = typeof SessionClaims.Type;
140
141const WebSocketClaims = Schema.Struct({
142 v: Schema.Literal(1),
143 kind: Schema.Literal("websocket"),
144 sid: AuthSessionId,
145 iat: Schema.Number,
146 exp: Schema.Number,
147});
148type WebSocketClaims = typeof WebSocketClaims.Type;
149
150const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
151const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
152
153function createDefaultClientMetadata(): AuthClientMetadata {
154 return {
155 deviceType: "unknown",
156 };
157}
158
159function toClientMetadata(record: {
160 readonly label: string | null;
161 readonly ipAddress: string | null;
162 readonly userAgent: string | null;
163 readonly deviceType: AuthClientMetadata["deviceType"];
164 readonly os: string | null;
165 readonly browser: string | null;
166}): AuthClientMetadata {
167 return {
168 ...(record.label ? { label: record.label } : {}),
169 ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
170 ...(record.userAgent ? { userAgent: record.userAgent } : {}),
171 deviceType: record.deviceType,
172 ...(record.os ? { os: record.os } : {}),
173 ...(record.browser ? { browser: record.browser } : {}),
174 };
175}
176
177function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
178 return {
179 ...input,
180 current: false,
181 };
182}
183
184const toSessionCredentialInternalError = (message: string) => (cause: unknown) =>
185 new SessionCredentialInternalError({
186 message,
187 cause,
188 });
189
190export const make = Effect.fn("makeSessionStore")(function* () {
191 const crypto = yield* Crypto.Crypto;
192 const serverConfig = yield* ServerConfig;
193 const secretStore = yield* ServerSecretStore.ServerSecretStore;
194 const authSessions = yield* AuthSessionRepository;
195 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
196 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
197 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
198 const cookieName = resolveSessionCookieName({
199 mode: serverConfig.mode,
200 port: serverConfig.port,
201 });
202
203 const emitUpsert = (clientSession: AuthClientSession) =>
204 PubSub.publish(changesPubSub, {
205 type: "clientUpserted",
206 clientSession,
207 }).pipe(Effect.asVoid);
208
209 const emitRemoved = (sessionId: AuthSessionId) =>
210 PubSub.publish(changesPubSub, {
211 type: "clientRemoved",
212 sessionId,
213 }).pipe(Effect.asVoid);
214
215 const loadActiveSession = (sessionId: AuthSessionId) =>
216 Effect.gen(function* () {
217 const row = yield* authSessions.getById({ sessionId });
218 if (Option.isNone(row) || row.value.revokedAt !== null) {
219 return Option.none<AuthClientSession>();
220 }
221
222 const connectedSessions = yield* Ref.get(connectedSessionsRef);
223 return Option.some(
224 toAuthClientSession({
225 sessionId: row.value.sessionId,
226 subject: row.value.subject,
227 scopes: row.value.scopes,
228 method: row.value.method,
229 client: toClientMetadata(row.value.client),
230 issuedAt: row.value.issuedAt,
231 expiresAt: row.value.expiresAt,
232 lastConnectedAt: row.value.lastConnectedAt,
233 connected: connectedSessions.has(row.value.sessionId),
234 }),
235 );
236 });
237
238 const markConnected: SessionStoreShape["markConnected"] = (sessionId) =>
239 Ref.modify(connectedSessionsRef, (current) => {
240 const next = new Map(current);
241 const wasDisconnected = !next.has(sessionId);
242 next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
243 return [wasDisconnected, next] as const;
244 }).pipe(
245 Effect.flatMap((wasDisconnected) =>
246 wasDisconnected
247 ? DateTime.now.pipe(
248 Effect.flatMap((lastConnectedAt) =>
249 authSessions.setLastConnectedAt({
250 sessionId,
251 lastConnectedAt,
252 }),
253 ),
254 )
255 : Effect.void,
256 ),
257 Effect.flatMap(() => loadActiveSession(sessionId)),
258 Effect.flatMap((session) =>
259 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
260 ),
261 Effect.catchCause((cause) =>
262 Effect.logError("Failed to publish connected-session auth update.").pipe(
263 Effect.annotateLogs({
264 sessionId,
265 cause,
266 }),
267 ),
268 ),
269 Effect.withSpan("SessionStore.markConnected"),
270 );
271
272 const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) =>
273 Ref.update(connectedSessionsRef, (current) => {
274 const next = new Map(current);
275 const remaining = (next.get(sessionId) ?? 0) - 1;
276 if (remaining > 0) {
277 next.set(sessionId, remaining);
278 } else {
279 next.delete(sessionId);
280 }
281 return next;
282 }).pipe(
283 Effect.flatMap(() => loadActiveSession(sessionId)),
284 Effect.flatMap((session) =>
285 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
286 ),
287 Effect.catchCause((cause) =>
288 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
289 Effect.annotateLogs({
290 sessionId,
291 cause,
292 }),
293 ),
294 ),
295 Effect.withSpan("SessionStore.markDisconnected"),
296 );
297
298 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
299 const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")(
300 function* (input) {
301 const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4);
302 const issuedAt = yield* DateTime.now;
303 const expiresAt = DateTime.add(issuedAt, {
304 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
305 });
306 const claims: SessionClaims = {
307 v: 1,
308 kind: "session",
309 sid: sessionId,
310 sub: input?.subject ?? "browser",
311 scopes: input?.scopes ?? AuthStandardClientScopes,
312 method: input?.method ?? "browser-session-cookie",
313 iat: issuedAt.epochMilliseconds,
314 exp: expiresAt.epochMilliseconds,
315 };
316
317 const encodedPayload = yield* encodeClaims(claims).pipe(
318 Effect.map(base64UrlEncode),
319 Effect.mapError(
320 (cause) =>
321 new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
322 ),
323 );
324 const signature = signPayload(encodedPayload, signingSecret);
325 const client = input?.client ?? createDefaultClientMetadata();
326 yield* authSessions.create({
327 sessionId,
328 subject: claims.sub,
329 scopes: claims.scopes,
330 method: claims.method,
331 client: {
332 label: client.label ?? null,
333 ipAddress: client.ipAddress ?? null,
334 userAgent: client.userAgent ?? null,
335 deviceType: client.deviceType,
336 os: client.os ?? null,
337 browser: client.browser ?? null,
338 },
339 issuedAt,
340 expiresAt,
341 });
342 yield* emitUpsert(
343 toAuthClientSession({
344 sessionId,
345 subject: claims.sub,
346 scopes: claims.scopes,
347 method: claims.method,
348 client,
349 issuedAt,
350 expiresAt,
351 lastConnectedAt: null,
352 connected: false,
353 }),
354 );
355
356 return {
357 sessionId,
358 token: `${encodedPayload}.${signature}`,
359 method: claims.method,
360 client,
361 expiresAt: expiresAt,
362 scopes: claims.scopes,
363 } satisfies IssuedSession;
364 },
365 Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),
366 );
367
368 const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")(
369 function* (token) {
370 const [encodedPayload, signature] = token.split(".");
371 if (!encodedPayload || !signature) {
372 return yield* new SessionCredentialInvalidError({
373 message: "Malformed session token.",
374 });
375 }
376
377 const expectedSignature = signPayload(encodedPayload, signingSecret);
378 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
379 return yield* new SessionCredentialInvalidError({
380 message: "Invalid session token signature.",
381 });
382 }
383
384 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
385 Effect.mapError(
386 (cause) =>
387 new SessionCredentialInvalidError({
388 message: "Invalid session token payload.",
389 cause,
390 }),
391 ),
392 );
393
394 const now = yield* Clock.currentTimeMillis;
395 if (claims.exp <= now) {
396 return yield* new SessionCredentialInvalidError({
397 message: "Session token expired.",
398 });
399 }
400
401 const row = yield* authSessions.getById({ sessionId: claims.sid });
402 if (Option.isNone(row)) {
403 return yield* new SessionCredentialInvalidError({
404 message: "Unknown session token.",
405 });
406 }
407 if (row.value.revokedAt !== null) {
408 return yield* new SessionCredentialInvalidError({
409 message: "Session token revoked.",
410 });
411 }
412
413 const expiresAt = DateTime.make(claims.exp);
414 if (Option.isNone(expiresAt)) {
415 return yield* new SessionCredentialInvalidError({
416 message: "Invalid `exp` claim",
417 });
418 }
419
420 return {
421 sessionId: claims.sid,
422 token,
423 method: claims.method,
424 client: toClientMetadata(row.value.client),
425 expiresAt: expiresAt.value,
426 subject: claims.sub,
427 scopes: claims.scopes,
428 } satisfies VerifiedSession;
429 },
430 Effect.mapError((cause) =>
431 cause._tag === "SessionCredentialInvalidError"
432 ? cause
433 : new SessionCredentialInternalError({
434 message: "Failed to verify session credential.",
435 cause,
436 }),
437 ),
438 );
439
440 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
441 const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn(
442 "SessionStore.issueWebSocketToken",
443 )(
444 function* (sessionId, input) {
445 const issuedAt = yield* DateTime.now;
446 const expiresAt = DateTime.add(issuedAt, {
447 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
448 });
449 const claims: WebSocketClaims = {
450 v: 1,
451 kind: "websocket",
452 sid: sessionId,
453 iat: issuedAt.epochMilliseconds,
454 exp: expiresAt.epochMilliseconds,
455 };
456 const encodedPayload = yield* encodeWsClaims(claims).pipe(
457 Effect.map(base64UrlEncode),
458 Effect.mapError(
459 (cause) =>
460 new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
461 ),
462 );
463 const signature = signPayload(encodedPayload, signingSecret);
464 return {
465 token: `${encodedPayload}.${signature}`,
466 expiresAt,
467 };
468 },
469 Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")),
470 );
471
472 const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn(
473 "SessionStore.verifyWebSocketToken",
474 )(
475 function* (token) {
476 const [encodedPayload, signature] = token.split(".");
477 if (!encodedPayload || !signature) {
478 return yield* new SessionCredentialInvalidError({
479 message: "Malformed websocket token.",
480 });
481 }
482
483 const expectedSignature = signPayload(encodedPayload, signingSecret);
484 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
485 return yield* new SessionCredentialInvalidError({
486 message: "Invalid websocket token signature.",
487 });
488 }
489
490 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
491 Effect.mapError(
492 (cause) =>
493 new SessionCredentialInvalidError({
494 message: "Invalid websocket token payload.",
495 cause,
496 }),
497 ),
498 );
499
500 const now = yield* Clock.currentTimeMillis;
501 if (claims.exp <= now) {
502 return yield* new SessionCredentialInvalidError({
503 message: "Websocket token expired.",
504 });
505 }
506
507 const row = yield* authSessions.getById({ sessionId: claims.sid });
508 if (Option.isNone(row)) {
509 return yield* new SessionCredentialInvalidError({
510 message: "Unknown websocket session.",
511 });
512 }
513 if (row.value.expiresAt.epochMilliseconds <= now) {
514 return yield* new SessionCredentialInvalidError({
515 message: "Websocket session expired.",
516 });
517 }
518 if (row.value.revokedAt !== null) {
519 return yield* new SessionCredentialInvalidError({
520 message: "Websocket session revoked.",
521 });
522 }
523
524 return {
525 sessionId: row.value.sessionId,
526 token,
527 method: row.value.method,
528 client: toClientMetadata(row.value.client),
529 expiresAt: row.value.expiresAt,
530 subject: row.value.subject,
531 scopes: row.value.scopes,
532 } satisfies VerifiedSession;
533 },
534 Effect.mapError((cause) =>
535 cause._tag === "SessionCredentialInvalidError"
536 ? cause
537 : new SessionCredentialInternalError({
538 message: "Failed to verify websocket token.",
539 cause,
540 }),
541 ),
542 );
543
544 const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")(
545 function* () {
546 const now = yield* DateTime.now;
547 const connectedSessions = yield* Ref.get(connectedSessionsRef);
548 const rows = yield* authSessions.listActive({ now });
549
550 return rows.map((row) =>
551 toAuthClientSession({
552 sessionId: row.sessionId,
553 subject: row.subject,
554 scopes: row.scopes,
555 method: row.method,
556 client: toClientMetadata(row.client),
557 issuedAt: row.issuedAt,
558 expiresAt: row.expiresAt,
559 lastConnectedAt: row.lastConnectedAt,
560 connected: connectedSessions.has(row.sessionId),
561 }),
562 );
563 },
564 Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")),
565 );
566
567 const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")(
568 function* (sessionId) {
569 const revokedAt = yield* DateTime.now;
570 const revoked = yield* authSessions.revoke({
571 sessionId,
572 revokedAt,
573 });
574 if (revoked) {
575 yield* Ref.update(connectedSessionsRef, (current) => {
576 const next = new Map(current);
577 next.delete(sessionId);
578 return next;
579 });
580 yield* emitRemoved(sessionId);
581 }
582 return revoked;
583 },
584 Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")),
585 );
586
587 const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn(
588 "SessionStore.revokeAllExcept",
589 )(
590 function* (sessionId) {
591 const revokedAt = yield* DateTime.now;
592 const revokedSessionIds = yield* authSessions.revokeAllExcept({
593 currentSessionId: sessionId,
594 revokedAt,
595 });
596 if (revokedSessionIds.length > 0) {
597 yield* Ref.update(connectedSessionsRef, (current) => {
598 const next = new Map(current);
599 for (const revokedSessionId of revokedSessionIds) {
600 next.delete(revokedSessionId);
601 }
602 return next;
603 });
604 yield* Effect.forEach(
605 revokedSessionIds,
606 (revokedSessionId) => emitRemoved(revokedSessionId),
607 {
608 concurrency: "unbounded",
609 discard: true,
610 },
611 );
612 }
613 return revokedSessionIds.length;
614 },
615 Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")),
616 );
617
618 return {
619 cookieName,
620 issue,
621 verify,
622 issueWebSocketToken,
623 verifyWebSocketToken,
624 listActive,
625 get streamChanges() {
626 return Stream.fromPubSub(changesPubSub);
627 },
628 revoke,
629 revokeAllExcept,
630 markConnected,
631 markDisconnected,
632 } satisfies SessionStoreShape;
633});
634
635export const layer = Layer.effect(SessionStore, make()).pipe(
636 Layer.provideMerge(AuthSessionRepositoryLive),
637);
apps/server/src/auth/http.ts+340-234
......@@ -1,265 +1,371 @@
11import {
2 type AuthBearerBootstrapResult,
3 AuthBootstrapInput,
4 AuthCreatePairingCredentialInput,
5 AuthRevokeClientSessionInput,
6 AuthRevokePairingLinkInput,
7 type AuthWebSocketTokenResult,
2 AuthAccessReadScope,
3 AuthAccessWriteScope,
4 AuthStandardClientScopes,
5 AuthOrchestrationOperateScope,
6 AuthOrchestrationReadScope,
7 AuthRelayReadScope,
8 AuthRelayWriteScope,
9 AuthReviewWriteScope,
10 AuthTerminalOperateScope,
11 EnvironmentAuthInvalidError,
12 type EnvironmentAuthInvalidReason,
13 EnvironmentHttpApi,
14 EnvironmentInternalError,
15 type EnvironmentInternalErrorReason,
16 EnvironmentOperationForbiddenError,
17 EnvironmentRequestInvalidError,
18 type EnvironmentRequestInvalidReason,
19 EnvironmentScopeRequiredError,
20 EnvironmentAuthenticatedAuth,
21 EnvironmentAuthenticatedPrincipal,
822} from "@t3tools/contracts";
23import type { AuthEnvironmentScope } from "@t3tools/contracts";
24import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
25import { causeErrorTag } from "@t3tools/shared/observability";
926import * as DateTime from "effect/DateTime";
1027import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
28import * as Layer from "effect/Layer";
29import * as Cookies from "effect/unstable/http/Cookies";
30import * as HttpEffect from "effect/unstable/http/HttpEffect";
31import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
32import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
1333
14import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
15import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
34import * as EnvironmentAuth from "./EnvironmentAuth.ts";
35import * as SessionStore from "./SessionStore.ts";
1636import { deriveAuthClientMetadata } from "./utils.ts";
17import { browserApiCorsHeaders } from "../httpCors.ts";
1837
19export const respondToAuthError = (error: AuthError) =>
20 Effect.gen(function* () {
21 if ((error.status ?? 500) >= 500) {
22 yield* Effect.logError("auth route failed", {
23 message: error.message,
24 cause: error.cause,
25 });
26 }
27 return HttpServerResponse.jsonUnsafe(
28 {
29 error: error.message,
30 },
31 { status: error.status ?? 500, headers: browserApiCorsHeaders },
32 );
33 });
38export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(
39 Effect.map((span) => span.traceId),
40 Effect.orElseSucceed(() => "unavailable"),
41);
3442
35export const authSessionRouteLayer = HttpRouter.add(
36 "GET",
37 "/api/auth/session",
38 Effect.gen(function* () {
43export function annotateEnvironmentRequest(endpoint: string) {
44 return Effect.gen(function* () {
3945 const request = yield* HttpServerRequest.HttpServerRequest;
40 const serverAuth = yield* ServerAuth;
41 const session = yield* serverAuth.getSessionState(request);
42 return HttpServerResponse.jsonUnsafe(session, {
43 status: 200,
44 headers: browserApiCorsHeaders,
46 const url = HttpServerRequest.toURL(request);
47 const traceId = yield* currentEnvironmentTraceId;
48
49 yield* Effect.addFinalizer((exit) =>
50 exit._tag === "Failure"
51 ? Effect.logWarning("environment api request failed", {
52 endpoint,
53 traceId,
54 errorTag: causeErrorTag(exit.cause),
55 cause: exit.cause,
56 })
57 : Effect.void,
58 );
59 yield* Effect.annotateLogsScoped({ "environment.endpoint": endpoint, traceId });
60 yield* Effect.annotateCurrentSpan({
61 "environment.endpoint": endpoint,
62 "http.request.method": request.method,
63 "url.path": url._tag === "Some" ? url.value.pathname : "unknown",
4564 });
46 }),
47);
65 });
66}
4867
49const PairingCredentialRequestHeaders = Schema.Struct({
50 "content-length": Schema.optionalKey(Schema.String),
51 "content-type": Schema.optionalKey(Schema.String),
52 "transfer-encoding": Schema.optionalKey(Schema.String),
53});
68export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) {
69 return currentEnvironmentTraceId.pipe(
70 Effect.flatMap((traceId) =>
71 Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })),
72 ),
73 );
74}
5475
55function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) {
56 const contentLengthHeader = headers["content-length"];
57 if (typeof contentLengthHeader === "string") {
58 const contentLength = Number.parseInt(contentLengthHeader, 10);
59 if (Number.isFinite(contentLength)) {
60 return contentLength > 0;
61 }
62 }
63 return typeof headers["transfer-encoding"] === "string";
76export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidReason) {
77 return currentEnvironmentTraceId.pipe(
78 Effect.flatMap((traceId) =>
79 Effect.fail(new EnvironmentRequestInvalidError({ code: "invalid_request", reason, traceId })),
80 ),
81 );
6482}
6583
66export const authBootstrapRouteLayer = HttpRouter.add(
67 "POST",
68 "/api/auth/bootstrap",
69 Effect.gen(function* () {
70 const request = yield* HttpServerRequest.HttpServerRequest;
71 const serverAuth = yield* ServerAuth;
72 const sessions = yield* SessionCredentialService;
73 const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(
74 Effect.mapError(
75 (cause) =>
76 new AuthError({
77 message: "Invalid bootstrap payload.",
78 status: 400,
79 cause,
80 }),
84export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) {
85 return currentEnvironmentTraceId.pipe(
86 Effect.flatMap((traceId) =>
87 Effect.fail(
88 new EnvironmentScopeRequiredError({
89 code: "insufficient_scope",
90 requiredScope,
91 traceId,
92 }),
8193 ),
82 );
83 const result = yield* serverAuth.exchangeBootstrapCredential(
84 payload.credential,
85 deriveAuthClientMetadata({ request }),
86 );
87
88 return yield* HttpServerResponse.jsonUnsafe(result.response, {
89 status: 200,
90 headers: browserApiCorsHeaders,
91 }).pipe(
92 HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
93 expires: DateTime.toDate(result.response.expiresAt),
94 httpOnly: true,
95 path: "/",
96 sameSite: "lax",
97 }),
98 );
99 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
100);
94 ),
95 );
96}
10197
102export const authBearerBootstrapRouteLayer = HttpRouter.add(
103 "POST",
104 "/api/auth/bootstrap/bearer",
105 Effect.gen(function* () {
106 const request = yield* HttpServerRequest.HttpServerRequest;
107 const serverAuth = yield* ServerAuth;
108 const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(
109 Effect.mapError(
110 (cause) =>
111 new AuthError({
112 message: "Invalid bootstrap payload.",
113 status: 400,
114 cause,
115 }),
98function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_allowed") {
99 return currentEnvironmentTraceId.pipe(
100 Effect.flatMap((traceId) =>
101 Effect.fail(
102 new EnvironmentOperationForbiddenError({
103 code: "operation_forbidden",
104 reason,
105 traceId,
106 }),
116107 ),
117 );
118 const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession(
119 payload.credential,
120 deriveAuthClientMetadata({ request }),
121 );
122 return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
123 status: 200,
124 headers: browserApiCorsHeaders,
125 });
126 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
127);
128
129export const authWebSocketTokenRouteLayer = HttpRouter.add(
130 "POST",
131 "/api/auth/ws-token",
132 Effect.gen(function* () {
133 const request = yield* HttpServerRequest.HttpServerRequest;
134 const serverAuth = yield* ServerAuth;
135 const session = yield* serverAuth.authenticateHttpRequest(request);
136 const result = yield* serverAuth.issueWebSocketToken(session);
137 return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
138 status: 200,
139 headers: browserApiCorsHeaders,
140 });
141 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
142);
108 ),
109 );
110}
143111
144export const authPairingCredentialRouteLayer = HttpRouter.add(
145 "POST",
146 "/api/auth/pairing-token",
147 Effect.gen(function* () {
148 const serverAuth = yield* ServerAuth;
149 const request = yield* HttpServerRequest.HttpServerRequest;
150 const session = yield* serverAuth.authenticateHttpRequest(request);
151 if (session.role !== "owner") {
152 return yield* new AuthError({
153 message: "Only owner sessions can create pairing credentials.",
154 status: 403,
112export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {
113 return Effect.gen(function* () {
114 const traceId = yield* currentEnvironmentTraceId;
115 if (error !== undefined) {
116 yield* Effect.logError("environment api operation failed", {
117 reason,
118 traceId,
119 cause: error,
155120 });
156121 }
157 const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe(
158 Effect.mapError(
159 (cause) =>
160 new AuthError({
161 message: "Invalid pairing credential request headers.",
162 status: 400,
163 cause,
164 }),
165 ),
166 );
167 const payload = hasRequestBody(headers)
168 ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe(
169 Effect.mapError(
170 (cause) =>
171 new AuthError({
172 message: "Invalid pairing credential payload.",
173 status: 400,
174 cause,
175 }),
176 ),
177 )
178 : {};
179 const result = yield* serverAuth.issuePairingCredential(payload);
180 return HttpServerResponse.jsonUnsafe(result, { status: 200 });
181 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
182);
122 return yield* new EnvironmentInternalError({ code: "internal_error", reason, traceId });
123 });
124}
183125
184const authenticateOwnerSession = Effect.gen(function* () {
185 const request = yield* HttpServerRequest.HttpServerRequest;
186 const serverAuth = yield* ServerAuth;
187 const session = yield* serverAuth.authenticateHttpRequest(request);
188 if (session.role !== "owner") {
189 return yield* new AuthError({
190 message: "Only owner sessions can manage network access.",
191 status: 403,
192 });
126export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* (
127 scope: AuthEnvironmentScope,
128) {
129 const session = yield* EnvironmentAuthenticatedPrincipal;
130 if (!session.scopes.has(scope)) {
131 return yield* failEnvironmentScopeRequired(scope);
193132 }
194 return { serverAuth, session } as const;
133 return session;
195134});
196135
197export const authPairingLinksRouteLayer = HttpRouter.add(
198 "GET",
199 "/api/auth/pairing-links",
136export const environmentAuthenticatedAuthLayer = Layer.effect(
137 EnvironmentAuthenticatedAuth,
200138 Effect.gen(function* () {
201 const { serverAuth } = yield* authenticateOwnerSession;
202 const pairingLinks = yield* serverAuth.listPairingLinks();
203 return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 });
204 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
205);
206
207export const authPairingLinksRevokeRouteLayer = HttpRouter.add(
208 "POST",
209 "/api/auth/pairing-links/revoke",
210 Effect.gen(function* () {
211 const { serverAuth } = yield* authenticateOwnerSession;
212 const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe(
213 Effect.mapError(
214 (cause) =>
215 new AuthError({
216 message: "Invalid revoke pairing link payload.",
217 status: 400,
218 cause,
139 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
140 return (httpEffect) =>
141 Effect.gen(function* () {
142 const request = yield* HttpServerRequest.HttpServerRequest;
143 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
144 Effect.catchTags({
145 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
146 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
219147 }),
220 ),
221 );
222 const revoked = yield* serverAuth.revokePairingLink(payload.id);
223 return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });
224 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
148 );
149 return yield* httpEffect.pipe(
150 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
151 ...session,
152 scopes: new Set(session.scopes),
153 }),
154 );
155 });
156 }),
225157);
226158
227export const authClientsRouteLayer = HttpRouter.add(
228 "GET",
229 "/api/auth/clients",
230 Effect.gen(function* () {
231 const { serverAuth, session } = yield* authenticateOwnerSession;
232 const clients = yield* serverAuth.listClientSessions(session.sessionId);
233 return HttpServerResponse.jsonUnsafe(clients, { status: 200 });
234 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
235);
159export const authHttpApiLayer = HttpApiBuilder.group(
160 EnvironmentHttpApi,
161 "auth",
162 Effect.fnUntraced(function* (handlers) {
163 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
164 const sessions = yield* SessionStore.SessionStore;
236165
237export const authClientsRevokeRouteLayer = HttpRouter.add(
238 "POST",
239 "/api/auth/clients/revoke",
240 Effect.gen(function* () {
241 const { serverAuth, session } = yield* authenticateOwnerSession;
242 const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe(
243 Effect.mapError(
244 (cause) =>
245 new AuthError({
246 message: "Invalid revoke client payload.",
247 status: 400,
248 cause,
249 }),
250 ),
251 );
252 const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId);
253 return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });
254 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
255);
166 return handlers
167 .handle(
168 "session",
169 Effect.fn("environment.auth.session")(
170 function* (args) {
171 yield* annotateEnvironmentRequest(args.endpoint.name);
172 const request = yield* HttpServerRequest.HttpServerRequest;
173 return yield* serverAuth.getSessionState(request);
174 },
175 Effect.catchTag("ServerAuthInternalError", (error) =>
176 failEnvironmentInternal("internal_error", error),
177 ),
178 ),
179 )
180 .handle(
181 "browserSession",
182 Effect.fn("environment.auth.browserSession")(
183 function* (args) {
184 yield* annotateEnvironmentRequest(args.endpoint.name);
185 const request = yield* HttpServerRequest.HttpServerRequest;
186 const result = yield* serverAuth.createBrowserSession(
187 args.payload.credential,
188 deriveAuthClientMetadata({ request }),
189 );
190 const sessionCookies = yield* Effect.fromResult(
191 Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {
192 expires: DateTime.toDate(result.response.expiresAt),
193 httpOnly: true,
194 path: "/",
195 sameSite: "lax",
196 }),
197 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));
256198
257export const authClientsRevokeOthersRouteLayer = HttpRouter.add(
258 "POST",
259 "/api/auth/clients/revoke-others",
260 Effect.gen(function* () {
261 const { serverAuth, session } = yield* authenticateOwnerSession;
262 const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);
263 return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 });
264 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
199 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
200 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
201 );
202 return result.response;
203 },
204 Effect.catchTags({
205 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
206 ServerAuthInternalError: (error) =>
207 failEnvironmentInternal("browser_session_issuance_failed", error),
208 }),
209 ),
210 )
211 .handle(
212 "token",
213 Effect.fn("environment.auth.token")(
214 function* (args) {
215 yield* annotateEnvironmentRequest(args.endpoint.name);
216 const request = yield* HttpServerRequest.HttpServerRequest;
217 const requestedScopes =
218 args.payload.scope === undefined
219 ? undefined
220 : parseAllowedOAuthScope({
221 value: args.payload.scope,
222 allowedScopes: new Set<AuthEnvironmentScope>([
223 AuthOrchestrationReadScope,
224 AuthOrchestrationOperateScope,
225 AuthTerminalOperateScope,
226 AuthReviewWriteScope,
227 AuthAccessReadScope,
228 AuthAccessWriteScope,
229 AuthRelayReadScope,
230 AuthRelayWriteScope,
231 ]),
232 });
233 if (requestedScopes === null)
234 return yield* failEnvironmentInvalidRequest("invalid_scope");
235 return yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
236 args.payload.subject_token,
237 requestedScopes,
238 deriveAuthClientMetadata({
239 request,
240 presented: {
241 ...(args.payload.client_label ? { label: args.payload.client_label } : {}),
242 ...(args.payload.client_device_type
243 ? { deviceType: args.payload.client_device_type }
244 : {}),
245 ...(args.payload.client_os ? { os: args.payload.client_os } : {}),
246 },
247 }),
248 );
249 },
250 Effect.catchTags({
251 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
252 ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason),
253 ServerAuthInternalError: (error) =>
254 failEnvironmentInternal("access_token_issuance_failed", error),
255 }),
256 ),
257 )
258 .handle(
259 "webSocketTicket",
260 Effect.fn("environment.auth.webSocketTicket")(
261 function* (args) {
262 yield* annotateEnvironmentRequest(args.endpoint.name);
263 const session = yield* EnvironmentAuthenticatedPrincipal;
264 return yield* serverAuth.issueWebSocketTicket(session);
265 },
266 Effect.catchTag("ServerAuthInternalError", (error) =>
267 failEnvironmentInternal("websocket_ticket_issuance_failed", error),
268 ),
269 ),
270 )
271 .handle(
272 "pairingCredential",
273 Effect.fn("environment.auth.pairingCredential")(
274 function* (args) {
275 yield* annotateEnvironmentRequest(args.endpoint.name);
276 const session = yield* requireEnvironmentScope(AuthAccessWriteScope);
277 const delegatedScopes = args.payload.scopes ?? AuthStandardClientScopes;
278 if (
279 delegatedScopes.length === 0 ||
280 new Set<AuthEnvironmentScope>(delegatedScopes).size !== delegatedScopes.length
281 ) {
282 return yield* failEnvironmentInvalidRequest("invalid_scope");
283 }
284 for (const delegatedScope of delegatedScopes) {
285 if (!session.scopes.has(delegatedScope)) {
286 return yield* failEnvironmentScopeRequired(delegatedScope);
287 }
288 }
289 return yield* serverAuth.issuePairingCredential(args.payload);
290 },
291 Effect.catchTag("ServerAuthInternalError", (error) =>
292 failEnvironmentInternal("pairing_credential_issuance_failed", error),
293 ),
294 ),
295 )
296 .handle(
297 "pairingLinks",
298 Effect.fn("environment.auth.pairingLinks")(
299 function* (args) {
300 yield* annotateEnvironmentRequest(args.endpoint.name);
301 yield* requireEnvironmentScope(AuthAccessReadScope);
302 return yield* serverAuth.listPairingLinks();
303 },
304 Effect.catchTag("ServerAuthInternalError", (error) =>
305 failEnvironmentInternal("pairing_links_load_failed", error),
306 ),
307 ),
308 )
309 .handle(
310 "revokePairingLink",
311 Effect.fn("environment.auth.revokePairingLink")(
312 function* (args) {
313 yield* annotateEnvironmentRequest(args.endpoint.name);
314 yield* requireEnvironmentScope(AuthAccessWriteScope);
315 const revoked = yield* serverAuth.revokePairingLink(args.payload.id);
316 return { revoked };
317 },
318 Effect.catchTag("ServerAuthInternalError", (error) =>
319 failEnvironmentInternal("pairing_link_revoke_failed", error),
320 ),
321 ),
322 )
323 .handle(
324 "clients",
325 Effect.fn("environment.auth.clients")(
326 function* (args) {
327 yield* annotateEnvironmentRequest(args.endpoint.name);
328 const session = yield* requireEnvironmentScope(AuthAccessReadScope);
329 return yield* serverAuth.listClientSessions(session.sessionId);
330 },
331 Effect.catchTag("ServerAuthInternalError", (error) =>
332 failEnvironmentInternal("client_sessions_load_failed", error),
333 ),
334 ),
335 )
336 .handle(
337 "revokeClient",
338 Effect.fn("environment.auth.revokeClient")(
339 function* (args) {
340 yield* annotateEnvironmentRequest(args.endpoint.name);
341 const session = yield* requireEnvironmentScope(AuthAccessWriteScope);
342 const revoked = yield* serverAuth.revokeClientSession(
343 session.sessionId,
344 args.payload.sessionId,
345 );
346 return { revoked };
347 },
348 Effect.catchTags({
349 ServerAuthForbiddenOperationError: (error) =>
350 failEnvironmentOperationForbidden(error.reason),
351 ServerAuthInternalError: (error) =>
352 failEnvironmentInternal("client_session_revoke_failed", error),
353 }),
354 ),
355 )
356 .handle(
357 "revokeOtherClients",
358 Effect.fn("environment.auth.revokeOtherClients")(
359 function* (args) {
360 yield* annotateEnvironmentRequest(args.endpoint.name);
361 const session = yield* requireEnvironmentScope(AuthAccessWriteScope);
362 const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);
363 return { revokedCount };
364 },
365 Effect.catchTag("ServerAuthInternalError", (error) =>
366 failEnvironmentInternal("client_session_revoke_failed", error),
367 ),
368 ),
369 );
370 }),
265371);
apps/server/src/auth/utils.test.ts+28
......@@ -23,4 +23,32 @@ describe("deriveAuthClientMetadata", () => {
2323 os: "macOS",
2424 });
2525 });
26
27 it("applies client-presented display identity without replacing transport metadata", () => {
28 const metadata = deriveAuthClientMetadata({
29 request: {
30 headers: {
31 "user-agent":
32 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36",
33 },
34 source: {
35 remoteAddress: "::ffff:192.168.213.72",
36 },
37 } as never,
38 presented: {
39 label: "T3 Code Mobile",
40 deviceType: "mobile",
41 os: "iOS",
42 },
43 });
44
45 expect(metadata).toMatchObject({
46 label: "T3 Code Mobile",
47 browser: "Electron",
48 deviceType: "mobile",
49 ipAddress: "192.168.213.72",
50 os: "iOS",
51 });
52 expect(metadata.userAgent).toContain("Electron/36.3.2");
53 });
2654});
apps/server/src/auth/utils.ts+9-5
......@@ -1,4 +1,8 @@
1import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts";
1import type {
2 AuthClientMetadata,
3 AuthClientMetadataDeviceType,
4 AuthClientPresentationMetadata,
5} from "@t3tools/contracts";
26import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
37import * as Crypto from "node:crypto";
48
......@@ -115,17 +119,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined {
115119
116120export function deriveAuthClientMetadata(input: {
117121 readonly request: HttpServerRequest.HttpServerRequest;
118 readonly label?: string;
122 readonly presented?: AuthClientPresentationMetadata;
119123}): AuthClientMetadata {
120124 const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);
121125 const ipAddress = readRemoteAddressFromSource(input.request.source);
122 const os = inferOs(userAgent);
126 const os = input.presented?.os ?? inferOs(userAgent);
123127 const browser = inferBrowser(userAgent);
124128 return {
125 ...(input.label ? { label: input.label } : {}),
129 ...(input.presented?.label ? { label: input.presented.label } : {}),
126130 ...(ipAddress ? { ipAddress } : {}),
127131 ...(userAgent ? { userAgent } : {}),
128 deviceType: inferDeviceType(userAgent),
132 deviceType: input.presented?.deviceType ?? inferDeviceType(userAgent),
129133 ...(os ? { os } : {}),
130134 ...(browser ? { browser } : {}),
131135 };
apps/server/src/bin.test.ts+35-15
......@@ -6,12 +6,15 @@ import { join } from "node:path";
66
77import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
88import * as NodeServices from "@effect/platform-node/NodeServices";
9import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts";
910import * as NetService from "@t3tools/shared/Net";
1011import { assert, it } from "@effect/vitest";
1112import * as Effect from "effect/Effect";
1213import * as Layer from "effect/Layer";
1314import * as HttpRouter from "effect/unstable/http/HttpRouter";
1415import * as HttpServer from "effect/unstable/http/HttpServer";
16import * as HttpApi from "effect/unstable/httpapi/HttpApi";
17import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
1518import * as CliError from "effect/unstable/cli/CliError";
1619import * as TestConsole from "effect/testing/TestConsole";
1720import { Command } from "effect/unstable/cli";
......@@ -20,10 +23,7 @@ import { cli } from "./bin.ts";
2023import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
2124import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
2225import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
23import {
24 orchestrationDispatchRouteLayer,
25 orchestrationSnapshotRouteLayer,
26} from "./orchestration/http.ts";
26import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
2727import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
2828import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
2929import {
......@@ -31,10 +31,12 @@ import {
3131 persistServerRuntimeState,
3232} from "./serverRuntimeState.ts";
3333import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
34import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
35import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
34import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
36import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
3637
3738const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
39class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
3840
3941const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
4042const runCliWithRuntime = (args: ReadonlyArray<string>) =>
......@@ -105,18 +107,18 @@ const readPersistedSnapshot = (baseDir: string) =>
105107const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
106108 Effect.gen(function* () {
107109 const config = yield* makeCliTestServerConfig(baseDir);
108 const routesLayer = Layer.mergeAll(
109 orchestrationSnapshotRouteLayer,
110 orchestrationDispatchRouteLayer,
110 const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe(
111 Layer.provide(orchestrationHttpApiLayer),
112 Layer.provide(environmentAuthenticatedAuthLayer),
111113 );
112114 const appLayer = HttpRouter.serve(routesLayer, {
113115 disableListenLog: true,
114116 disableLogger: true,
115117 }).pipe(
116118 Layer.provideMerge(
117 ServerAuthLive.pipe(
119 EnvironmentAuth.layer.pipe(
118120 Layer.provideMerge(SqlitePersistenceLayerLive),
119 Layer.provide(ServerSecretStoreLive),
121 Layer.provide(ServerSecretStore.layer),
120122 ),
121123 ),
122124 Layer.provideMerge(makeProjectPersistenceLayer(config)),
......@@ -214,7 +216,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
214216 const issued = JSON.parse(issuedOutput.output) as {
215217 readonly sessionId: string;
216218 readonly token: string;
217 readonly role: string;
219 readonly scopes: ReadonlyArray<string>;
218220 };
219221 const listedOutput = yield* captureStdout(
220222 runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
......@@ -223,15 +225,33 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
223225 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
224226 readonly sessionId: string;
225227 readonly token?: string;
226 readonly role: string;
228 readonly scopes: ReadonlyArray<string>;
227229 }>;
228230
229231 assert.equal(typeof issued.sessionId, "string");
230232 assert.equal(typeof issued.token, "string");
231 assert.equal(issued.role, "owner");
233 assert.deepEqual(issued.scopes, [
234 "orchestration:read",
235 "orchestration:operate",
236 "terminal:operate",
237 "review:write",
238 "relay:read",
239 "access:read",
240 "access:write",
241 "relay:write",
242 ]);
232243 assert.equal(listed.length, 1);
233244 assert.equal(listed[0]?.sessionId, issued.sessionId);
234 assert.equal(listed[0]?.role, "owner");
245 assert.deepEqual(listed[0]?.scopes, [
246 "orchestration:read",
247 "orchestration:operate",
248 "terminal:operate",
249 "review:write",
250 "relay:read",
251 "access:read",
252 "access:write",
253 "relay:write",
254 ]);
235255 assert.equal("token" in (listed[0] ?? {}), false);
236256 }),
237257 );
apps/server/src/cli/auth.ts+33-34
......@@ -1,4 +1,8 @@
1import { AuthSessionId } from "@t3tools/contracts";
1import {
2 AuthAdministrativeScopes,
3 AuthSessionId,
4 AuthStandardClientScopes,
5} from "@t3tools/contracts";
26import * as Console from "effect/Console";
37import * as Effect from "effect/Effect";
48import * as Layer from "effect/Layer";
......@@ -6,9 +10,8 @@ import * as Option from "effect/Option";
610import * as References from "effect/References";
711import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
812
9import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts";
10import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts";
11import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts";
13import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
14
1215import {
1316 formatIssuedPairingCredential,
1417 formatIssuedSession,
......@@ -23,9 +26,9 @@ import {
2326 resolveCliAuthConfig,
2427} from "./config.ts";
2528
26const runWithAuthControlPlane = <A, E>(
29const runWithEnvironmentAuth = <A, E>(
2730 flags: CliAuthLocationFlags,
28 run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>,
31 run: (environmentAuth: EnvironmentAuth.EnvironmentAuthShape) => Effect.Effect<A, E>,
2932 options?: {
3033 readonly quietLogs?: boolean;
3134 },
......@@ -35,11 +38,11 @@ const runWithAuthControlPlane = <A, E>(
3538 const config = yield* resolveCliAuthConfig(flags, logLevel);
3639 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
3740 return yield* Effect.gen(function* () {
38 const authControlPlane = yield* AuthControlPlane;
39 return yield* run(authControlPlane);
41 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
42 return yield* run(environmentAuth);
4043 }).pipe(
4144 Effect.provide(
42 Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(
45 Layer.mergeAll(EnvironmentAuth.runtimeLayer).pipe(
4346 Layer.provide(Layer.succeed(ServerConfig, config)),
4447 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
4548 ),
......@@ -58,11 +61,6 @@ const jsonFlag = Flag.boolean("json").pipe(
5861 Flag.withDefault(false),
5962);
6063
61const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe(
62 Flag.withDescription("Role for the issued bearer session."),
63 Flag.withDefault("owner"),
64);
65
6664const labelFlag = Flag.string("label").pipe(
6765 Flag.withDescription("Optional human-readable label."),
6866 Flag.optional,
......@@ -92,12 +90,12 @@ const pairingCreateCommand = Command.make("create", {
9290}).pipe(
9391 Command.withDescription("Issue a new client pairing token."),
9492 Command.withHandler((flags) =>
95 runWithAuthControlPlane(
93 runWithEnvironmentAuth(
9694 flags,
97 (authControlPlane) =>
95 (environmentAuth) =>
9896 Effect.gen(function* () {
99 const issued = yield* authControlPlane.createPairingLink({
100 role: "client",
97 const issued = yield* environmentAuth.createPairingLink({
98 scopes: AuthStandardClientScopes,
10199 subject: "one-time-token",
102100 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
103101 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
......@@ -121,11 +119,13 @@ const pairingListCommand = Command.make("list", {
121119}).pipe(
122120 Command.withDescription("List active client pairing tokens without revealing their secrets."),
123121 Command.withHandler((flags) =>
124 runWithAuthControlPlane(
122 runWithEnvironmentAuth(
125123 flags,
126 (authControlPlane) =>
124 (environmentAuth) =>
127125 Effect.gen(function* () {
128 const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" });
126 const pairingLinks = yield* environmentAuth.listPairingLinks({
127 excludeSubjects: [EnvironmentAuth.INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT],
128 });
129129 yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json }));
130130 }),
131131 {
......@@ -141,9 +141,9 @@ const pairingRevokeCommand = Command.make("revoke", {
141141}).pipe(
142142 Command.withDescription("Revoke an active client pairing token."),
143143 Command.withHandler((flags) =>
144 runWithAuthControlPlane(flags, (authControlPlane) =>
144 runWithEnvironmentAuth(flags, (environmentAuth) =>
145145 Effect.gen(function* () {
146 const revoked = yield* authControlPlane.revokePairingLink(flags.id);
146 const revoked = yield* environmentAuth.revokePairingLink(flags.id);
147147 yield* Console.log(
148148 revoked
149149 ? `Revoked pairing credential ${flags.id}.\n`
......@@ -162,20 +162,19 @@ const pairingCommand = Command.make("pairing").pipe(
162162const sessionIssueCommand = Command.make("issue", {
163163 ...authLocationFlags,
164164 ttl: ttlFlag,
165 role: sessionRoleFlag,
166165 label: labelFlag,
167166 subject: subjectFlag,
168167 tokenOnly: tokenOnlyFlag,
169168 json: jsonFlag,
170169}).pipe(
171 Command.withDescription("Issue a bearer session token for headless or remote clients."),
170 Command.withDescription("Issue a scoped bearer access token for headless or remote clients."),
172171 Command.withHandler((flags) =>
173 runWithAuthControlPlane(
172 runWithEnvironmentAuth(
174173 flags,
175 (authControlPlane) =>
174 (environmentAuth) =>
176175 Effect.gen(function* () {
177 const issued = yield* authControlPlane.issueSession({
178 role: flags.role,
176 const issued = yield* environmentAuth.issueSession({
177 scopes: AuthAdministrativeScopes,
179178 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
180179 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
181180 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),
......@@ -200,11 +199,11 @@ const sessionListCommand = Command.make("list", {
200199}).pipe(
201200 Command.withDescription("List active sessions without revealing bearer tokens."),
202201 Command.withHandler((flags) =>
203 runWithAuthControlPlane(
202 runWithEnvironmentAuth(
204203 flags,
205 (authControlPlane) =>
204 (environmentAuth) =>
206205 Effect.gen(function* () {
207 const sessions = yield* authControlPlane.listSessions();
206 const sessions = yield* environmentAuth.listSessions();
208207 yield* Console.log(formatSessionList(sessions, { json: flags.json }));
209208 }),
210209 {
......@@ -223,9 +222,9 @@ const sessionRevokeCommand = Command.make("revoke", {
223222}).pipe(
224223 Command.withDescription("Revoke an active session."),
225224 Command.withHandler((flags) =>
226 runWithAuthControlPlane(flags, (authControlPlane) =>
225 runWithEnvironmentAuth(flags, (environmentAuth) =>
227226 Effect.gen(function* () {
228 const revoked = yield* authControlPlane.revokeSession(flags.sessionId);
227 const revoked = yield* environmentAuth.revokeSession(flags.sessionId);
229228 yield* Console.log(
230229 revoked
231230 ? `Revoked session ${flags.sessionId}.\n`
apps/server/src/cli/project.ts+61-77
......@@ -1,6 +1,9 @@
11import {
22 CommandId,
3 OrchestrationReadModel,
3 AuthAdministrativeScopes,
4 EnvironmentHttpApi,
5 EnvironmentHttpCommonError,
6 type OrchestrationReadModel,
47 ProjectId,
58 type ClientOrchestrationCommand,
69} from "@t3tools/contracts";
......@@ -18,16 +21,11 @@ import * as Path from "effect/Path";
1821import * as References from "effect/References";
1922import * as Schema from "effect/Schema";
2023import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
21import {
22 FetchHttpClient,
23 HttpClient,
24 HttpClientRequest,
25 HttpClientResponse,
26} from "effect/unstable/http";
27
28import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts";
29import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts";
30import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts";
24import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
25import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
26
27import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
28
3129import { ServerConfig, type ServerConfigShape } from "../config.ts";
3230import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
3331import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
......@@ -78,50 +76,53 @@ const ProjectCliRuntimeLive = Layer.mergeAll(
7876);
7977
8078const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
81const OrchestrationHttpErrorResponse = Schema.Struct({
82 error: Schema.String,
83});
84
79const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
8580const withProjectCliSessionToken = <A, E, R>(
86 authControlPlane: AuthControlPlaneShape,
81 environmentAuth: EnvironmentAuth.EnvironmentAuthShape,
8782 run: (token: string) => Effect.Effect<A, E, R>,
8883) =>
8984 Effect.acquireUseRelease(
90 authControlPlane.issueSession({
91 role: "owner",
85 environmentAuth.issueSession({
86 scopes: AuthAdministrativeScopes,
9287 label: "t3 project cli",
9388 }),
9489 (issued) => run(issued.token),
95 (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
90 (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
9691 );
9792
9893const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
9994 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
10095
101const runLiveServerRequest = <A, E extends Error, R>(
102 request: HttpClientRequest.HttpClientRequest,
103 handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
104) =>
105 Effect.gen(function* () {
106 const httpClient = yield* HttpClient.HttpClient;
107 const response = yield* httpClient.execute(request);
108 return yield* handle(response);
109 }).pipe(withProjectCliLiveServerTimeout);
110
111const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>
112 HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);
113
114const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>
115 HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(
116 Effect.map((body) => body.error),
117 Effect.catch(() => Effect.succeed(null)),
118 Effect.map((body) => {
119 if (typeof body === "string" && body.trim().length > 0) {
120 return body;
121 }
122 return `Server request failed with status ${response.status}.`;
96const failLiveServerRequest = (cause: unknown) => {
97 if (isEnvironmentHttpCommonError(cause)) {
98 return Effect.fail(
99 new ProjectCommandError({
100 message: `Server request failed (${cause.code}, trace ${cause.traceId}).`,
101 }),
102 );
103 }
104 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
105 return Effect.fail(
106 new ProjectCommandError({
107 message: `Server request failed with undeclared status ${cause.response.status}.`,
108 }),
109 );
110 }
111 return Effect.fail(
112 new ProjectCommandError({
113 message: `Failed to call running server: ${String(cause)}.`,
123114 }),
124115 );
116};
117
118const makeLiveServerClient = (origin: string) =>
119 Effect.gen(function* () {
120 const httpClient = yield* HttpClient.HttpClient;
121 return yield* HttpApiClient.makeWith(EnvironmentHttpApi, {
122 baseUrl: origin,
123 httpClient,
124 });
125 });
125126
126127const normalizeWorkspaceRootForProjectCommand = Effect.fn(
127128 "normalizeWorkspaceRootForProjectCommand",
......@@ -193,42 +194,25 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (
193194});
194195
195196const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
196 runLiveServerRequest(
197 HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(
198 HttpClientRequest.acceptJson,
199 HttpClientRequest.bearerToken(bearerToken),
200 ),
201 HttpClientResponse.matchStatus({
202 "2xx": decodeOrchestrationReadModelResponse,
203 orElse: (response) =>
204 readErrorMessageFromResponse(response).pipe(
205 Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))),
206 ),
207 }),
208 );
197 Effect.gen(function* () {
198 const client = yield* makeLiveServerClient(origin);
199 return yield* client.orchestration.snapshot({
200 headers: { authorization: `Bearer ${bearerToken}` },
201 });
202 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));
209203
210204const dispatchLiveOrchestrationCommand = (
211205 origin: string,
212206 bearerToken: string,
213207 command: ProjectCliDispatchCommand,
214208) =>
215 HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(
216 HttpClientRequest.acceptJson,
217 HttpClientRequest.bearerToken(bearerToken),
218 HttpClientRequest.bodyJson(command),
219 Effect.flatMap((request) =>
220 runLiveServerRequest(
221 request,
222 HttpClientResponse.matchStatus({
223 "2xx": () => Effect.void,
224 orElse: (response) =>
225 readErrorMessageFromResponse(response).pipe(
226 Effect.flatMap((message) => Effect.fail(new ProjectCommandError({ message }))),
227 ),
228 }),
229 ),
230 ),
231 );
209 Effect.gen(function* () {
210 const client = yield* makeLiveServerClient(origin);
211 yield* client.orchestration.dispatch({
212 headers: { authorization: `Bearer ${bearerToken}` },
213 payload: command,
214 } as Parameters<typeof client.orchestration.dispatch>[0]);
215 }).pipe(withProjectCliLiveServerTimeout, Effect.catch(failLiveServerRequest));
232216
233217const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
234218 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
......@@ -236,13 +220,13 @@ const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
236220});
237221
238222const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
239 function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {
223 function* (environmentAuth: EnvironmentAuth.EnvironmentAuthShape, config: ServerConfigShape) {
240224 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
241225 if (Option.isNone(runtimeState)) {
242226 return Option.none<{ readonly origin: string }>();
243227 }
244228
245 const attempt = withProjectCliSessionToken(authControlPlane, (token) =>
229 const attempt = withProjectCliSessionToken(environmentAuth, (token) =>
246230 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
247231 Effect.as({
248232 origin: runtimeState.value.origin,
......@@ -279,11 +263,11 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
279263 const minimumLogLevel = config.logLevel;
280264
281265 return yield* Effect.gen(function* () {
282 const authControlPlane = yield* AuthControlPlane;
283 const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);
266 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
267 const liveMode = yield* tryResolveLiveProjectExecutionMode(environmentAuth, config);
284268
285269 if (Option.isSome(liveMode)) {
286 return yield* withProjectCliSessionToken(authControlPlane, (token) =>
270 return yield* withProjectCliSessionToken(environmentAuth, (token) =>
287271 Effect.gen(function* () {
288272 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
289273 const output = yield* run({
......@@ -314,7 +298,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* (
314298 }).pipe(Effect.provide(offlineRuntimeLayer));
315299 }).pipe(
316300 Effect.provide(
317 Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(
301 Layer.mergeAll(EnvironmentAuth.runtimeLayer, WorkspacePathsLive).pipe(
318302 Layer.provideMerge(FetchHttpClient.layer),
319303 Layer.provide(Layer.succeed(ServerConfig, config)),
320304 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
apps/server/src/cliAuthFormat.test.ts+6-6
......@@ -13,7 +13,7 @@ it("formats issued pairing credentials with the secret and optional pair URL", (
1313 {
1414 id: "pairing-1",
1515 credential: "secret-pairing-token",
16 role: "client",
16 scopes: ["orchestration:read"],
1717 subject: "one-time-token",
1818 createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"),
1919 expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"),
......@@ -33,7 +33,7 @@ it("formats pairing listings without exposing the secret token", () => {
3333 credential: "secret-pairing-token",
3434 subject: "one-time-token",
3535 label: "Phone",
36 role: "client",
36 scopes: ["orchestration:read"],
3737 createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"),
3838 expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"),
3939 },
......@@ -50,8 +50,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings
5050 {
5151 sessionId: "session-1" as never,
5252 token: "secret-session-token",
53 method: "bearer-session-token",
54 role: "owner",
53 method: "bearer-access-token",
54 scopes: ["orchestration:read", "access:write"],
5555 subject: "cli-issued-session",
5656 client: {
5757 label: "deploy-bot",
......@@ -66,8 +66,8 @@ it("formats issued sessions with the bearer token but omits tokens from listings
6666 [
6767 {
6868 sessionId: "session-1" as never,
69 method: "bearer-session-token",
70 role: "owner",
69 method: "bearer-access-token",
70 scopes: ["orchestration:read", "access:write"],
7171 subject: "cli-issued-session",
7272 client: {
7373 label: "deploy-bot",
apps/server/src/cliAuthFormat.ts+10-8
......@@ -1,7 +1,7 @@
11import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
22import * as DateTime from "effect/DateTime";
33
4import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts";
4import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts";
55
66const newline = "\n";
77
......@@ -47,7 +47,7 @@ export function formatIssuedPairingCredential(
4747 id: credential.id,
4848 credential: credential.credential,
4949 ...(credential.label ? { label: credential.label } : {}),
50 role: credential.role,
50 scopes: credential.scopes,
5151 expiresAt: toIsoString(credential.expiresAt),
5252 ...(pairUrl ? { pairUrl } : {}),
5353 },
......@@ -77,7 +77,7 @@ export function formatPairingCredentialList(
7777 credentials.map((credential) => ({
7878 id: credential.id,
7979 ...(credential.label ? { label: credential.label } : {}),
80 role: credential.role,
80 scopes: credential.scopes,
8181 createdAt: toIsoString(credential.createdAt),
8282 expiresAt: toIsoString(credential.expiresAt),
8383 })),
......@@ -95,7 +95,7 @@ export function formatPairingCredentialList(
9595 .map((credential) =>
9696 [
9797 `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,
98 ` role: ${credential.role}`,
98 ` scopes: ${credential.scopes.join(" ")}`,
9999 ` created: ${toIsoString(credential.createdAt)}`,
100100 ` expires: ${toIsoString(credential.expiresAt)}`,
101101 ].join(newline),
......@@ -121,7 +121,7 @@ export function formatIssuedSession(
121121 sessionId: session.sessionId,
122122 token: session.token,
123123 method: session.method,
124 role: session.role,
124 scopes: session.scopes,
125125 subject: session.subject,
126126 client: session.client,
127127 expiresAt: toIsoString(session.expiresAt),
......@@ -133,7 +133,8 @@ export function formatIssuedSession(
133133
134134 return (
135135 [
136 `Issued ${session.role} bearer session ${session.sessionId}.`,
136 `Issued bearer access token ${session.sessionId}.`,
137 `Scopes: ${session.scopes.join(" ")}`,
137138 `Token: ${session.token}`,
138139 `Subject: ${session.subject}`,
139140 `Client: ${formatClientMetadata(session.client)}`,
......@@ -153,7 +154,7 @@ export function formatSessionList(
153154 sessions.map((session) => ({
154155 sessionId: session.sessionId,
155156 method: session.method,
156 role: session.role,
157 scopes: session.scopes,
157158 subject: session.subject,
158159 client: session.client,
159160 connected: session.connected,
......@@ -174,7 +175,8 @@ export function formatSessionList(
174175 sessions
175176 .map((session) =>
176177 [
177 `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`,
178 `${session.sessionId}${session.connected ? " connected" : ""}`,
179 ` scopes: ${session.scopes.join(" ")}`,
178180 ` method: ${session.method}`,
179181 ` subject: ${session.subject}`,
180182 ` client: ${formatClientMetadata(session.client)}`,
apps/server/src/http.ts+68-30
......@@ -1,4 +1,9 @@
11import Mime from "@effect/platform-node/Mime";
2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 EnvironmentHttpApi,
6} from "@t3tools/contracts";
27import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
38import * as Data from "effect/Data";
49import * as Effect from "effect/Effect";
......@@ -13,7 +18,9 @@ import {
1318 HttpRouter,
1419 HttpServerResponse,
1520 HttpServerRequest,
21 HttpServerRespondable,
1622} from "effect/unstable/http";
23import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
1724import { OtlpTracer } from "effect/unstable/observability";
1825
1926import {
......@@ -25,14 +32,15 @@ import { resolveAttachmentPathById } from "./attachmentStore.ts";
2532import { resolveStaticDir, ServerConfig } from "./config.ts";
2633import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
2734import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
28import { ServerAuth } from "./auth/Services/ServerAuth.ts";
29import { respondToAuthError } from "./auth/http.ts";
30import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3136import {
32 browserApiCorsAllowedHeaders,
33 browserApiCorsAllowedMethods,
34 browserApiCorsHeaders,
35} from "./httpCors.ts";
37 annotateEnvironmentRequest,
38 failEnvironmentScopeRequired,
39 failEnvironmentAuthInvalid,
40 failEnvironmentInternal,
41} from "./auth/http.ts";
42import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
43import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
3644
3745const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
3846const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
......@@ -40,8 +48,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
4048const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4149
4250export const browserApiCorsLayer = HttpRouter.cors({
43 allowedMethods: [...browserApiCorsAllowedMethods],
44 allowedHeaders: [...browserApiCorsAllowedHeaders],
51 allowedMethods: browserApiCorsAllowedMethods,
52 allowedHeaders: browserApiCorsAllowedHeaders,
4553 maxAge: 600,
4654});
4755
......@@ -61,23 +69,35 @@ export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
6169 return redirectUrl.toString();
6270}
6371
64const requireAuthenticatedRequest = Effect.gen(function* () {
65 const request = yield* HttpServerRequest.HttpServerRequest;
66 const serverAuth = yield* ServerAuth;
67 yield* serverAuth.authenticateHttpRequest(request);
68});
69
70export const serverEnvironmentRouteLayer = HttpRouter.add(
71 "GET",
72 "/.well-known/t3/environment",
72const authenticateRawRouteWithScope = (
73 scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
74) =>
7375 Effect.gen(function* () {
74 const descriptor = yield* Effect.service(ServerEnvironment).pipe(
75 Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
76 const request = yield* HttpServerRequest.HttpServerRequest;
77 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
78 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
79 Effect.catchTags({
80 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
81 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
82 }),
83 );
84 if (!session.scopes.includes(scope)) {
85 return yield* failEnvironmentScopeRequired(scope);
86 }
87 });
88
89export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(
90 EnvironmentHttpApi,
91 "metadata",
92 Effect.fnUntraced(function* (handlers) {
93 const serverEnvironment = yield* ServerEnvironment;
94 return handlers.handle(
95 "descriptor",
96 Effect.fn("environment.metadata.descriptor")(function* (args) {
97 yield* annotateEnvironmentRequest(args.endpoint.name);
98 return yield* serverEnvironment.getDescriptor;
99 }),
76100 );
77 return HttpServerResponse.jsonUnsafe(descriptor, {
78 status: 200,
79 headers: browserApiCorsHeaders,
80 });
81101 }),
82102);
83103
......@@ -90,7 +110,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
90110 "POST",
91111 OTLP_TRACES_PROXY_PATH,
92112 Effect.gen(function* () {
93 yield* requireAuthenticatedRequest;
113 yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);
94114 const request = yield* HttpServerRequest.HttpServerRequest;
95115 const config = yield* ServerConfig;
96116 const otlpTracesUrl = config.otlpTracesUrl;
......@@ -132,14 +152,20 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
132152 Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),
133153 ),
134154 );
135 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
155 }).pipe(
156 Effect.catchTags({
157 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
158 EnvironmentInternalError: HttpServerRespondable.toResponse,
159 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
160 }),
161 ),
136162);
137163
138164export const attachmentsRouteLayer = HttpRouter.add(
139165 "GET",
140166 `${ATTACHMENTS_ROUTE_PREFIX}/*`,
141167 Effect.gen(function* () {
142 yield* requireAuthenticatedRequest;
168 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
143169 const request = yield* HttpServerRequest.HttpServerRequest;
144170 const url = HttpServerRequest.toURL(request);
145171 if (Option.isNone(url)) {
......@@ -188,14 +214,20 @@ export const attachmentsRouteLayer = HttpRouter.add(
188214 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
189215 ),
190216 );
191 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
217 }).pipe(
218 Effect.catchTags({
219 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
220 EnvironmentInternalError: HttpServerRespondable.toResponse,
221 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
222 }),
223 ),
192224);
193225
194226export const projectFaviconRouteLayer = HttpRouter.add(
195227 "GET",
196228 "/api/project-favicon",
197229 Effect.gen(function* () {
198 yield* requireAuthenticatedRequest;
230 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
199231 const request = yield* HttpServerRequest.HttpServerRequest;
200232 const url = HttpServerRequest.toURL(request);
201233 if (Option.isNone(url)) {
......@@ -229,7 +261,13 @@ export const projectFaviconRouteLayer = HttpRouter.add(
229261 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
230262 ),
231263 );
232 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
264 }).pipe(
265 Effect.catchTags({
266 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
267 EnvironmentInternalError: HttpServerRespondable.toResponse,
268 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
269 }),
270 ),
233271);
234272
235273export const staticAndDevRouteLayer = HttpRouter.add(
apps/server/src/orchestration/http.ts+48-84
......@@ -1,95 +1,59 @@
11import {
2 ClientOrchestrationCommand,
3 OrchestrationDispatchCommandError,
4 OrchestrationGetSnapshotError,
5 type OrchestrationReadModel,
2 AuthOrchestrationOperateScope,
3 AuthOrchestrationReadScope,
4 EnvironmentHttpApi,
65} from "@t3tools/contracts";
76import * as Effect from "effect/Effect";
8import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
7import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
98
10import { ServerAuth } from "../auth/Services/ServerAuth.ts";
119import { normalizeDispatchCommand } from "./Normalizer.ts";
10import {
11 annotateEnvironmentRequest,
12 failEnvironmentInternal,
13 failEnvironmentInvalidRequest,
14 requireEnvironmentScope,
15} from "../auth/http.ts";
1216import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
1317import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
1418
15const respondToOrchestrationHttpError = (
16 error: OrchestrationDispatchCommandError | OrchestrationGetSnapshotError,
17) =>
18 Effect.gen(function* () {
19 if (error._tag === "OrchestrationGetSnapshotError") {
20 yield* Effect.logError("orchestration http route failed", {
21 message: error.message,
22 cause: error.cause,
23 });
24 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 });
25 }
26
27 return HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 });
28 });
29
30const authenticateOwnerSession = Effect.gen(function* () {
31 const request = yield* HttpServerRequest.HttpServerRequest;
32 const serverAuth = yield* ServerAuth;
33 const session = yield* serverAuth.authenticateHttpRequest(request);
34 if (session.role !== "owner") {
35 return yield* new OrchestrationDispatchCommandError({
36 message: "Only owner sessions can manage projects.",
37 });
38 }
39 return session;
40});
41
42export const orchestrationSnapshotRouteLayer = HttpRouter.add(
43 "GET",
44 "/api/orchestration/snapshot",
45 Effect.gen(function* () {
46 yield* authenticateOwnerSession;
19export const orchestrationHttpApiLayer = HttpApiBuilder.group(
20 EnvironmentHttpApi,
21 "orchestration",
22 Effect.fnUntraced(function* (handlers) {
4723 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
48 const snapshot = yield* projectionSnapshotQuery.getSnapshot().pipe(
49 Effect.mapError(
50 (cause) =>
51 new OrchestrationGetSnapshotError({
52 message: "Failed to load orchestration snapshot.",
53 cause,
54 }),
55 ),
56 );
57 return HttpServerResponse.jsonUnsafe(snapshot satisfies OrchestrationReadModel, {
58 status: 200,
59 });
60 }).pipe(
61 Effect.catchTags({
62 OrchestrationDispatchCommandError: respondToOrchestrationHttpError,
63 OrchestrationGetSnapshotError: respondToOrchestrationHttpError,
64 }),
65 ),
66);
67
68export const orchestrationDispatchRouteLayer = HttpRouter.add(
69 "POST",
70 "/api/orchestration/dispatch",
71 Effect.gen(function* () {
72 yield* authenticateOwnerSession;
7324 const orchestrationEngine = yield* OrchestrationEngineService;
74 const command = yield* HttpServerRequest.schemaBodyJson(ClientOrchestrationCommand).pipe(
75 Effect.mapError(
76 (cause) =>
77 new OrchestrationDispatchCommandError({
78 message: "Invalid orchestration command payload.",
79 cause,
80 }),
81 ),
82 );
83 const normalizedCommand = yield* normalizeDispatchCommand(command);
84 const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe(
85 Effect.mapError(
86 (cause) =>
87 new OrchestrationDispatchCommandError({
88 message: "Failed to dispatch orchestration command.",
89 cause,
90 }),
91 ),
92 );
93 return HttpServerResponse.jsonUnsafe(result, { status: 200 });
94 }).pipe(Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError)),
25
26 return handlers
27 .handle(
28 "snapshot",
29 Effect.fn("environment.orchestration.snapshot")(function* (args) {
30 yield* annotateEnvironmentRequest(args.endpoint.name);
31 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
32 return yield* projectionSnapshotQuery
33 .getSnapshot()
34 .pipe(
35 Effect.catch((cause) =>
36 failEnvironmentInternal("orchestration_snapshot_failed", cause),
37 ),
38 );
39 }),
40 )
41 .handle(
42 "dispatch",
43 Effect.fn("environment.orchestration.dispatch")(function* (args) {
44 yield* annotateEnvironmentRequest(args.endpoint.name);
45 yield* requireEnvironmentScope(AuthOrchestrationOperateScope);
46 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(
47 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),
48 );
49 return yield* orchestrationEngine
50 .dispatch(normalizedCommand)
51 .pipe(
52 Effect.catch((cause) =>
53 failEnvironmentInternal("orchestration_dispatch_failed", cause),
54 ),
55 );
56 }),
57 );
58 }),
9559);
apps/server/src/persistence/Layers/AuthPairingLinks.ts+5-5
......@@ -38,7 +38,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
3838 id,
3939 credential,
4040 method,
41 role,
41 scopes,
4242 subject,
4343 label,
4444 created_at,
......@@ -50,7 +50,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
5050 ${input.id},
5151 ${input.credential},
5252 ${input.method},
53 ${input.role},
53 ${JSON.stringify(input.scopes)},
5454 ${input.subject},
5555 ${input.label},
5656 ${input.createdAt},
......@@ -76,7 +76,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
7676 id AS "id",
7777 credential AS "credential",
7878 method AS "method",
79 role AS "role",
79 scopes AS "scopes",
8080 subject AS "subject",
8181 label AS "label",
8282 created_at AS "createdAt",
......@@ -95,7 +95,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
9595 id AS "id",
9696 credential AS "credential",
9797 method AS "method",
98 role AS "role",
98 scopes AS "scopes",
9999 subject AS "subject",
100100 label AS "label",
101101 created_at AS "createdAt",
......@@ -133,7 +133,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
133133 id AS "id",
134134 credential AS "credential",
135135 method AS "method",
136 role AS "role",
136 scopes AS "scopes",
137137 subject AS "subject",
138138 label AS "label",
139139 created_at AS "createdAt",
apps/server/src/persistence/Layers/AuthSessions.ts+8-8
......@@ -1,4 +1,4 @@
1import { AuthSessionId } from "@t3tools/contracts";
1import { AuthEnvironmentScopes, AuthSessionId, ServerAuthSessionMethod } from "@t3tools/contracts";
22import * as SqlClient from "effect/unstable/sql/SqlClient";
33import * as SqlSchema from "effect/unstable/sql/SqlSchema";
44import * as Effect from "effect/Effect";
......@@ -26,8 +26,8 @@ import {
2626const AuthSessionDbRow = Schema.Struct({
2727 sessionId: AuthSessionId,
2828 subject: Schema.String,
29 role: Schema.Literals(["owner", "client"]),
30 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
29 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
30 method: ServerAuthSessionMethod,
3131 clientLabel: Schema.NullOr(Schema.String),
3232 clientIpAddress: Schema.NullOr(Schema.String),
3333 clientUserAgent: Schema.NullOr(Schema.String),
......@@ -44,7 +44,7 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco
4444 return {
4545 sessionId: row.sessionId,
4646 subject: row.subject,
47 role: row.role,
47 scopes: row.scopes,
4848 method: row.method,
4949 client: {
5050 label: row.clientLabel,
......@@ -78,7 +78,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
7878 INSERT INTO auth_sessions (
7979 session_id,
8080 subject,
81 role,
81 scopes,
8282 method,
8383 client_label,
8484 client_ip_address,
......@@ -93,7 +93,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
9393 VALUES (
9494 ${input.sessionId},
9595 ${input.subject},
96 ${input.role},
96 ${JSON.stringify(input.scopes)},
9797 ${input.method},
9898 ${input.client.label},
9999 ${input.client.ipAddress},
......@@ -116,7 +116,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
116116 SELECT
117117 session_id AS "sessionId",
118118 subject AS "subject",
119 role AS "role",
119 scopes AS "scopes",
120120 method AS "method",
121121 client_label AS "clientLabel",
122122 client_ip_address AS "clientIpAddress",
......@@ -141,7 +141,7 @@ const makeAuthSessionRepository = Effect.gen(function* () {
141141 SELECT
142142 session_id AS "sessionId",
143143 subject AS "subject",
144 role AS "role",
144 scopes AS "scopes",
145145 method AS "method",
146146 client_label AS "clientLabel",
147147 client_ip_address AS "clientIpAddress",
apps/server/src/persistence/Migrations.ts+2
......@@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"
4343import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
4444import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
4545import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
4647
4748/**
4849 * Migration loader with all migrations defined inline.
......@@ -85,6 +86,7 @@ export const migrationEntries = [
8586 [28, "ProjectionThreadSessionInstanceId", Migration0028],
8687 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
8788 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],
89 [31, "AuthAuthorizationScopes", Migration0031],
8890] as const;
8991
9092export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts created+80
......@@ -0,0 +1,80 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("031_AuthAuthorizationScopes", (it) => {
12 it.effect("invalidates role-based auth records and installs scoped auth tables", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 30 });
17
18 yield* sql`
19 INSERT INTO auth_pairing_links (
20 id,
21 credential,
22 method,
23 role,
24 subject,
25 created_at,
26 expires_at
27 )
28 VALUES (
29 'link-owner',
30 'bootstrap-owner',
31 'desktop-bootstrap',
32 'owner',
33 'desktop',
34 '2026-05-29T00:00:00.000Z',
35 '2026-05-29T01:00:00.000Z'
36 )
37 `;
38 yield* sql`
39 INSERT INTO auth_sessions (
40 session_id,
41 subject,
42 role,
43 method,
44 issued_at,
45 expires_at
46 )
47 VALUES (
48 'session-owner',
49 'desktop',
50 'owner',
51 'browser-session-cookie',
52 '2026-05-29T00:00:00.000Z',
53 '2026-05-29T01:00:00.000Z'
54 )
55 `;
56
57 yield* runMigrations({ toMigrationInclusive: 31 });
58
59 const pairingColumns = yield* sql<{ readonly name: string }>`
60 PRAGMA table_info(auth_pairing_links)
61 `;
62 const sessionColumns = yield* sql<{ readonly name: string }>`
63 PRAGMA table_info(auth_sessions)
64 `;
65 const pairingRows = yield* sql<{ readonly id: string }>`
66 SELECT id FROM auth_pairing_links
67 `;
68 const sessionRows = yield* sql<{ readonly sessionId: string }>`
69 SELECT session_id AS "sessionId" FROM auth_sessions
70 `;
71
72 assert.isTrue(pairingColumns.some((column) => column.name === "scopes"));
73 assert.isFalse(pairingColumns.some((column) => column.name === "role"));
74 assert.isTrue(sessionColumns.some((column) => column.name === "scopes"));
75 assert.isFalse(sessionColumns.some((column) => column.name === "role"));
76 assert.deepStrictEqual(pairingRows, []);
77 assert.deepStrictEqual(sessionRows, []);
78 }),
79 );
80});
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.ts created+57
......@@ -0,0 +1,57 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4/**
5 * This is an intentional alpha cutover: role-bearing credentials and sessions
6 * cannot safely be assigned new capabilities implicitly.
7 */
8export default Effect.gen(function* () {
9 const sql = yield* SqlClient.SqlClient;
10
11 yield* sql`DROP TABLE IF EXISTS auth_pairing_links`;
12 yield* sql`DROP TABLE IF EXISTS auth_sessions`;
13
14 yield* sql`
15 CREATE TABLE auth_pairing_links (
16 id TEXT PRIMARY KEY,
17 credential TEXT NOT NULL UNIQUE,
18 method TEXT NOT NULL,
19 scopes TEXT NOT NULL,
20 subject TEXT NOT NULL,
21 label TEXT,
22 created_at TEXT NOT NULL,
23 expires_at TEXT NOT NULL,
24 consumed_at TEXT,
25 revoked_at TEXT
26 )
27 `;
28
29 yield* sql`
30 CREATE INDEX idx_auth_pairing_links_active
31 ON auth_pairing_links(revoked_at, consumed_at, expires_at)
32 `;
33
34 yield* sql`
35 CREATE TABLE auth_sessions (
36 session_id TEXT PRIMARY KEY,
37 subject TEXT NOT NULL,
38 scopes TEXT NOT NULL,
39 method TEXT NOT NULL,
40 client_label TEXT,
41 client_ip_address TEXT,
42 client_user_agent TEXT,
43 client_device_type TEXT NOT NULL DEFAULT 'unknown',
44 client_os TEXT,
45 client_browser TEXT,
46 issued_at TEXT NOT NULL,
47 expires_at TEXT NOT NULL,
48 last_connected_at TEXT,
49 revoked_at TEXT
50 )
51 `;
52
53 yield* sql`
54 CREATE INDEX idx_auth_sessions_active
55 ON auth_sessions(revoked_at, expires_at, issued_at)
56 `;
57});
apps/server/src/persistence/Services/AuthPairingLinks.ts+3-2
......@@ -2,6 +2,7 @@ import * as Option from "effect/Option";
22import * as Schema from "effect/Schema";
33import * as Context from "effect/Context";
44import type * as Effect from "effect/Effect";
5import { AuthEnvironmentScopes } from "@t3tools/contracts";
56
67import type { AuthPairingLinkRepositoryError } from "../Errors.ts";
78
......@@ -9,7 +10,7 @@ export const AuthPairingLinkRecord = Schema.Struct({
910 id: Schema.String,
1011 credential: Schema.String,
1112 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),
12 role: Schema.Literals(["owner", "client"]),
13 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
1314 subject: Schema.String,
1415 label: Schema.NullOr(Schema.String),
1516 createdAt: Schema.DateTimeUtcFromString,
......@@ -23,7 +24,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({
2324 id: Schema.String,
2425 credential: Schema.String,
2526 method: Schema.Literals(["desktop-bootstrap", "one-time-token"]),
26 role: Schema.Literals(["owner", "client"]),
27 scopes: AuthEnvironmentScopes,
2728 subject: Schema.String,
2829 label: Schema.NullOr(Schema.String),
2930 createdAt: Schema.DateTimeUtcFromString,
apps/server/src/persistence/Services/AuthSessions.ts+10-5
......@@ -1,4 +1,9 @@
1import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts";
1import {
2 AuthClientMetadataDeviceType,
3 AuthEnvironmentScopes,
4 AuthSessionId,
5 ServerAuthSessionMethod,
6} from "@t3tools/contracts";
27import * as Option from "effect/Option";
38import * as Schema from "effect/Schema";
49import * as Context from "effect/Context";
......@@ -19,8 +24,8 @@ export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRe
1924export const AuthSessionRecord = Schema.Struct({
2025 sessionId: AuthSessionId,
2126 subject: Schema.String,
22 role: Schema.Literals(["owner", "client"]),
23 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
27 scopes: AuthEnvironmentScopes,
28 method: ServerAuthSessionMethod,
2429 client: AuthSessionClientMetadataRecord,
2530 issuedAt: Schema.DateTimeUtcFromString,
2631 expiresAt: Schema.DateTimeUtcFromString,
......@@ -32,8 +37,8 @@ export type AuthSessionRecord = typeof AuthSessionRecord.Type;
3237export const CreateAuthSessionInput = Schema.Struct({
3338 sessionId: AuthSessionId,
3439 subject: Schema.String,
35 role: Schema.Literals(["owner", "client"]),
36 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
40 scopes: AuthEnvironmentScopes,
41 method: ServerAuthSessionMethod,
3742 client: AuthSessionClientMetadataRecord,
3843 issuedAt: Schema.DateTimeUtcFromString,
3944 expiresAt: Schema.DateTimeUtcFromString,
apps/server/src/server.test.ts+395-93
......@@ -3,6 +3,9 @@ import * as NodeSocket from "@effect/platform-node/NodeSocket";
33import * as NodeServices from "@effect/platform-node/NodeServices";
44
55import {
6 AuthAccessTokenType,
7 AuthEnvironmentBootstrapTokenType,
8 AuthTokenExchangeGrantType,
69 CommandId,
710 DEFAULT_SERVER_SETTINGS,
811 EnvironmentId,
......@@ -46,6 +49,7 @@ import {
4649 HttpClient,
4750 HttpRouter,
4851 HttpServer,
52 UrlParams,
4953} from "effect/unstable/http";
5054import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
5155import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
......@@ -114,8 +118,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
114118import * as GitWorkflowService from "./git/GitWorkflowService.ts";
115119import * as ReviewService from "./review/ReviewService.ts";
116120import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
117import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
118import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
121import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
122import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
119123import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
120124import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
121125import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
......@@ -215,7 +219,10 @@ const browserOtlpTracingLayer = Layer.mergeAll(
215219);
216220
217221const makeAuthTestLayer = () =>
218 ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive));
222 EnvironmentAuth.layer.pipe(
223 Layer.provide(SqlitePersistenceMemory),
224 Layer.provide(ServerSecretStore.layer),
225 );
219226
220227const makeBrowserOtlpPayload = (spanName: string) =>
221228 Effect.gen(function* () {
......@@ -799,7 +806,7 @@ const bootstrapBrowserSession = (
799806 },
800807) =>
801808 Effect.gen(function* () {
802 const response = yield* HttpClient.post("/api/auth/bootstrap", {
809 const response = yield* HttpClient.post("/api/auth/browser-session", {
803810 headers: options?.headers,
804811 body: yield* HttpBody.json({ credential }),
805812 });
......@@ -815,23 +822,46 @@ const bootstrapBrowserSession = (
815822 };
816823 });
817824
818const bootstrapBearerSession = (
825const exchangeAccessToken = (
819826 credential = defaultDesktopBootstrapToken,
820827 options?: {
821828 readonly headers?: Record<string, string>;
829 readonly scope?: string;
830 readonly clientMetadata?: {
831 readonly label?: string;
832 readonly deviceType?: string;
833 readonly os?: string;
834 };
822835 },
823836) =>
824837 Effect.gen(function* () {
825 const response = yield* HttpClient.post("/api/auth/bootstrap/bearer", {
838 const response = yield* HttpClient.post("/oauth/token", {
826839 headers: options?.headers,
827 body: yield* HttpBody.json({ credential }),
840 body: HttpBody.urlParams(
841 UrlParams.fromInput({
842 grant_type: AuthTokenExchangeGrantType,
843 subject_token: credential,
844 subject_token_type: AuthEnvironmentBootstrapTokenType,
845 requested_token_type: AuthAccessTokenType,
846 scope:
847 options?.scope ??
848 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
849 ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),
850 ...(options?.clientMetadata?.deviceType
851 ? { client_device_type: options.clientMetadata.deviceType }
852 : {}),
853 ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),
854 }),
855 ),
828856 });
829857 const body = (yield* response.json) as {
830 readonly authenticated: boolean;
831 readonly sessionMethod: string;
832 readonly expiresAt: string;
833 readonly sessionToken?: string;
834 readonly error?: string;
858 readonly access_token?: string;
859 readonly issued_token_type?: string;
860 readonly token_type?: string;
861 readonly expires_in?: number;
862 readonly scope?: string;
863 readonly _tag?: string;
864 readonly message?: string;
835865 };
836866 return {
837867 response,
......@@ -863,20 +893,20 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra
863893
864894const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>
865895 Effect.gen(function* () {
866 const { response, body } = yield* bootstrapBearerSession(credential);
896 const { response, body } = yield* exchangeAccessToken(credential);
867897 if (response.status !== 200) {
868898 return yield* new AuthenticationGetterError({
869899 message: `Expected bearer bootstrap response to succeed, got ${response.status}`,
870900 });
871901 }
872902
873 if (!body.sessionToken) {
903 if (!body.access_token) {
874904 return yield* new AuthenticationGetterError({
875 message: "Expected bearer bootstrap response to include a session token.",
905 message: "Expected token exchange response to include an access token.",
876906 });
877907 }
878908
879 return body.sessionToken;
909 return body.access_token;
880910 });
881911
882912const extractSessionTokenFromSetCookie = (cookieHeader: string): string => {
......@@ -895,8 +925,16 @@ const splitHeaderTokens = (value: string | null) =>
895925 .filter((token) => token.length > 0)
896926 .toSorted();
897927
898const assertBrowserApiCorsHeaders = (headers: Readonly<Record<string, string | undefined>>) => {
928const assertBrowserApiCorsResponseHeaders = (
929 headers: Readonly<Record<string, string | undefined>>,
930) => {
899931 assert.equal(headers["access-control-allow-origin"], "*");
932};
933
934const assertBrowserApiCorsPreflightHeaders = (
935 headers: Readonly<Record<string, string | undefined>>,
936) => {
937 assertBrowserApiCorsResponseHeaders(headers);
900938 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [
901939 "GET",
902940 "OPTIONS",
......@@ -1039,7 +1077,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10391077 const body = (yield* response.json) as typeof testEnvironmentDescriptor;
10401078
10411079 assert.equal(response.status, 200);
1042 assertBrowserApiCorsHeaders(response.headers);
1080 assertBrowserApiCorsResponseHeaders(response.headers);
10431081 assert.deepEqual(body, testEnvironmentDescriptor);
10441082 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
10451083 );
......@@ -1063,10 +1101,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10631101 assert.equal(body.authenticated, false);
10641102 assert.equal(body.auth.policy, "desktop-managed-local");
10651103 assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]);
1066 assert.deepEqual(body.auth.sessionMethods, [
1067 "browser-session-cookie",
1068 "bearer-session-token",
1069 ]);
1104 assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]);
10701105 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
10711106 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
10721107 );
......@@ -1103,56 +1138,134 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11031138 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11041139 );
11051140
1106 it.effect(
1107 "bootstraps a bearer session and authenticates the session endpoint via authorization header",
1108 () =>
1109 Effect.gen(function* () {
1110 yield* buildAppUnderTest();
1141 it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>
1142 Effect.gen(function* () {
1143 yield* buildAppUnderTest();
11111144
1112 const { response: bootstrapResponse, body: bootstrapBody } =
1113 yield* bootstrapBearerSession();
1145 const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken();
11141146
1115 assert.equal(bootstrapResponse.status, 200);
1116 assert.equal(bootstrapBody.authenticated, true);
1117 assert.equal(bootstrapBody.sessionMethod, "bearer-session-token");
1118 assert.equal(typeof bootstrapBody.sessionToken, "string");
1119 assert.isTrue((bootstrapBody.sessionToken?.length ?? 0) > 0);
1147 assert.equal(bootstrapResponse.status, 200);
1148 assert.equal(tokenBody.issued_token_type, AuthAccessTokenType);
1149 assert.equal(tokenBody.token_type, "Bearer");
1150 assert.equal(
1151 tokenBody.scope,
1152 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
1153 );
1154 assert.equal(typeof tokenBody.access_token, "string");
1155 assert.isTrue((tokenBody.access_token?.length ?? 0) > 0);
11201156
1121 const sessionResponse = yield* HttpClient.get("/api/auth/session", {
1122 headers: {
1123 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
1124 },
1125 });
1126 const sessionBody = (yield* sessionResponse.json) as {
1127 readonly authenticated: boolean;
1128 readonly sessionMethod?: string;
1129 };
1157 const sessionResponse = yield* HttpClient.get("/api/auth/session", {
1158 headers: {
1159 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1160 },
1161 });
1162 const sessionBody = (yield* sessionResponse.json) as {
1163 readonly authenticated: boolean;
1164 readonly sessionMethod?: string;
1165 readonly scopes?: ReadonlyArray<string>;
1166 };
11301167
1131 assert.equal(sessionResponse.status, 200);
1132 assert.equal(sessionBody.authenticated, true);
1133 assert.equal(sessionBody.sessionMethod, "bearer-session-token");
1134 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1168 assert.equal(sessionResponse.status, 200);
1169 assert.equal(sessionBody.authenticated, true);
1170 assert.equal(sessionBody.sessionMethod, "bearer-access-token");
1171 assert.deepEqual(sessionBody.scopes, [
1172 "orchestration:read",
1173 "orchestration:operate",
1174 "terminal:operate",
1175 "review:write",
1176 "relay:read",
1177 "access:read",
1178 "access:write",
1179 "relay:write",
1180 ]);
1181 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11351182 );
11361183
1137 it.effect("issues short-lived websocket tokens for authenticated bearer sessions", () =>
1184 it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () =>
11381185 Effect.gen(function* () {
11391186 yield* buildAppUnderTest();
11401187
11411188 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1142 const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", {
1189 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
11431190 headers: {
11441191 authorization: `Bearer ${bearerToken}`,
11451192 },
11461193 });
1147 const wsTokenBody = (yield* wsTokenResponse.json) as {
1148 readonly token: string;
1194 const wsTicketBody = (yield* wsTicketResponse.json) as {
1195 readonly ticket: string;
11491196 readonly expiresAt: string;
11501197 };
11511198
1152 assert.equal(wsTokenResponse.status, 200);
1153 assert.equal(typeof wsTokenBody.token, "string");
1154 assert.isTrue(wsTokenBody.token.length > 0);
1155 assert.equal(typeof wsTokenBody.expiresAt, "string");
1199 assert.equal(wsTicketResponse.status, 200);
1200 assert.equal(typeof wsTicketBody.ticket, "string");
1201 assert.isTrue(wsTicketBody.ticket.length > 0);
1202 assert.equal(typeof wsTicketBody.expiresAt, "string");
1203 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1204 );
1205
1206 it.effect("does not allow management-only access tokens to operate the environment", () =>
1207 Effect.gen(function* () {
1208 yield* buildAppUnderTest();
1209
1210 const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken(
1211 defaultDesktopBootstrapToken,
1212 { scope: "access:write" },
1213 );
1214 assert.equal(exchangeResponse.status, 200);
1215 assert.equal(tokenBody.scope, "access:write");
1216 assert.isDefined(tokenBody.access_token);
1217
1218 const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1219 headers: {
1220 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1221 },
1222 body: yield* HttpBody.json({}),
1223 });
1224 const overbroadPairingBody = (yield* overbroadPairingResponse.json) as {
1225 readonly requiredScope: string;
1226 };
1227 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1228 headers: {
1229 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1230 },
1231 body: yield* HttpBody.json({ scopes: ["access:write"] }),
1232 });
1233 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1234 headers: {
1235 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1236 },
1237 });
1238 const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };
1239 const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", {
1240 headers: {
1241 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1242 },
1243 });
1244 const faviconBody = (yield* faviconResponse.json) as {
1245 readonly _tag: string;
1246 readonly code: string;
1247 readonly requiredScope: string;
1248 readonly traceId: string;
1249 };
1250
1251 assert.equal(overbroadPairingResponse.status, 403);
1252 assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");
1253 assert.equal(pairingResponse.status, 200);
1254 assert.equal(wsTicketResponse.status, 200);
1255 assert.equal(faviconResponse.status, 403);
1256 assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError");
1257 assert.equal(faviconBody.code, "insufficient_scope");
1258 assert.equal(faviconBody.requiredScope, "orchestration:read");
1259 assert.equal(typeof faviconBody.traceId, "string");
1260
1261 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
1262 const rpcError = yield* Effect.flip(
1263 Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),
1264 );
1265 assert.equal(rpcError._tag, "EnvironmentAuthorizationError");
1266 if (rpcError._tag === "EnvironmentAuthorizationError") {
1267 assert.equal(rpcError.requiredScope, "orchestration:read");
1268 }
11561269 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11571270 );
11581271
......@@ -1161,7 +1274,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11611274 yield* buildAppUnderTest();
11621275
11631276 const origin = crossOriginClientOrigin;
1164 const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession(
1277 const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(
11651278 defaultDesktopBootstrapToken,
11661279 {
11671280 headers: { origin },
......@@ -1169,13 +1282,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11691282 );
11701283
11711284 assert.equal(bootstrapResponse.status, 200);
1172 assertBrowserApiCorsHeaders(bootstrapResponse.headers);
1173 assert.equal(bootstrapBody.authenticated, true);
1174 assert.equal(typeof bootstrapBody.sessionToken, "string");
1285 assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers);
1286 assert.equal(tokenBody.token_type, "Bearer");
1287 assert.equal(typeof tokenBody.access_token, "string");
11751288
11761289 const sessionResponse = yield* HttpClient.get("/api/auth/session", {
11771290 headers: {
1178 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
1291 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
11791292 origin,
11801293 },
11811294 });
......@@ -1185,33 +1298,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11851298 };
11861299
11871300 assert.equal(sessionResponse.status, 200);
1188 assertBrowserApiCorsHeaders(sessionResponse.headers);
1301 assertBrowserApiCorsResponseHeaders(sessionResponse.headers);
11891302 assert.equal(sessionBody.authenticated, true);
1190 assert.equal(sessionBody.sessionMethod, "bearer-session-token");
1303 assert.equal(sessionBody.sessionMethod, "bearer-access-token");
11911304
1192 const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", {
1305 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
11931306 headers: {
1194 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
1307 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
11951308 origin,
11961309 },
11971310 });
1198 const wsTokenBody = (yield* wsTokenResponse.json) as {
1199 readonly token: string;
1311 const wsTicketBody = (yield* wsTicketResponse.json) as {
1312 readonly ticket: string;
12001313 };
12011314
1202 assert.equal(wsTokenResponse.status, 200);
1203 assertBrowserApiCorsHeaders(wsTokenResponse.headers);
1204 assert.equal(typeof wsTokenBody.token, "string");
1315 assert.equal(wsTicketResponse.status, 200);
1316 assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers);
1317 assert.equal(typeof wsTicketBody.ticket, "string");
12051318 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
12061319 );
12071320
12081321 it.effect(
1209 "responds to remote auth websocket-token preflight requests with authorization CORS headers",
1322 "responds to remote auth websocket-ticket preflight requests with authorization CORS headers",
12101323 () =>
12111324 Effect.gen(function* () {
12121325 yield* buildAppUnderTest();
12131326
1214 const response = yield* HttpClient.options("/api/auth/ws-token", {
1327 const response = yield* HttpClient.options("/api/auth/websocket-ticket", {
12151328 headers: {
12161329 origin: crossOriginClientOrigin,
12171330 "access-control-request-method": "POST",
......@@ -1220,26 +1333,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
12201333 });
12211334
12221335 assert.equal(response.status, 204);
1223 assertBrowserApiCorsHeaders(response.headers);
1336 assertBrowserApiCorsPreflightHeaders(response.headers);
12241337 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
12251338 );
12261339
1227 it.effect("includes CORS headers on remote websocket-token auth failures", () =>
1340 it.effect("includes CORS headers on remote websocket-ticket auth failures", () =>
12281341 Effect.gen(function* () {
12291342 yield* buildAppUnderTest();
12301343
1231 const response = yield* HttpClient.post("/api/auth/ws-token", {
1344 const response = yield* HttpClient.post("/api/auth/websocket-ticket", {
12321345 headers: {
12331346 origin: crossOriginClientOrigin,
12341347 },
12351348 });
12361349 const body = (yield* response.json) as {
1237 readonly error?: string;
1350 readonly _tag?: string;
1351 readonly code?: string;
1352 readonly reason?: string;
1353 readonly traceId?: string;
12381354 };
12391355
12401356 assert.equal(response.status, 401);
1241 assertBrowserApiCorsHeaders(response.headers);
1242 assert.equal(body.error, "Authentication required.");
1357 assertBrowserApiCorsResponseHeaders(response.headers);
1358 assert.equal(body._tag, "EnvironmentAuthInvalidError");
1359 assert.equal(body.code, "auth_invalid");
1360 assert.equal(body.reason, "missing_credential");
1361 assert.equal(typeof body.traceId, "string");
1362 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1363 );
1364
1365 it.effect("persists token exchange client display metadata for authorized-client listings", () =>
1366 Effect.gen(function* () {
1367 yield* buildAppUnderTest({
1368 config: {
1369 host: "0.0.0.0",
1370 },
1371 });
1372
1373 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1374 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1375 headers: {
1376 cookie: ownerCookie,
1377 },
1378 body: yield* HttpBody.json({}),
1379 });
1380 const pairingBody = (yield* pairingResponse.json) as {
1381 readonly credential: string;
1382 };
1383
1384 const { response } = yield* exchangeAccessToken(pairingBody.credential, {
1385 headers: {
1386 "user-agent": "undici",
1387 },
1388 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
1389 clientMetadata: {
1390 label: "T3 Code Mobile",
1391 deviceType: "mobile",
1392 os: "iOS",
1393 },
1394 });
1395
1396 const clientsResponse = yield* HttpClient.get("/api/auth/clients", {
1397 headers: {
1398 cookie: ownerCookie,
1399 },
1400 });
1401 const clients = (yield* clientsResponse.json) as ReadonlyArray<{
1402 readonly current: boolean;
1403 readonly client: {
1404 readonly label?: string;
1405 readonly deviceType: string;
1406 readonly ipAddress?: string;
1407 readonly os?: string;
1408 readonly userAgent?: string;
1409 };
1410 }>;
1411 const mobileClient = clients.find((client) => !client.current);
1412
1413 assert.equal(pairingResponse.status, 200);
1414 assert.equal(response.status, 200);
1415 assert.equal(clientsResponse.status, 200);
1416 assert.deepInclude(mobileClient?.client, {
1417 label: "T3 Code Mobile",
1418 deviceType: "mobile",
1419 os: "iOS",
1420 ipAddress: "127.0.0.1",
1421 userAgent: "undici",
1422 });
12431423 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
12441424 );
12451425
......@@ -1251,6 +1431,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
12511431 headers: {
12521432 cookie: yield* getAuthenticatedSessionCookieHeader(),
12531433 },
1434 body: yield* HttpBody.json({}),
12541435 });
12551436 const body = (yield* response.json) as {
12561437 readonly credential: string;
......@@ -1270,16 +1451,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
12701451 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
12711452 );
12721453
1454 it.effect("issues pairing credentials for bearer sessions with access management scope", () =>
1455 Effect.gen(function* () {
1456 yield* buildAppUnderTest();
1457
1458 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1459 const response = yield* HttpClient.post("/api/auth/pairing-token", {
1460 headers: {
1461 authorization: `Bearer ${bearerToken}`,
1462 },
1463 body: yield* HttpBody.json({ label: "Hosted web" }),
1464 });
1465 const body = (yield* response.json) as {
1466 readonly credential: string;
1467 readonly label?: string;
1468 };
1469
1470 assert.equal(response.status, 200);
1471 assert.isTrue(body.credential.length > 0);
1472 assert.equal(body.label, "Hosted web");
1473 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1474 );
1475
1476 it.effect("rejects pairing credentials with an empty scope grant", () =>
1477 Effect.gen(function* () {
1478 yield* buildAppUnderTest();
1479
1480 const response = yield* HttpClient.post("/api/auth/pairing-token", {
1481 headers: {
1482 cookie: yield* getAuthenticatedSessionCookieHeader(),
1483 },
1484 body: yield* HttpBody.json({ scopes: [] }),
1485 });
1486 const body = (yield* response.json) as {
1487 readonly code: string;
1488 readonly reason: string;
1489 };
1490
1491 assert.equal(response.status, 400);
1492 assert.equal(body.code, "invalid_request");
1493 assert.equal(body.reason, "invalid_scope");
1494 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1495 );
1496
12731497 it.effect("rejects unauthenticated pairing credential requests", () =>
12741498 Effect.gen(function* () {
12751499 yield* buildAppUnderTest();
12761500
1277 const response = yield* HttpClient.post("/api/auth/pairing-token");
1501 const response = yield* HttpClient.post("/api/auth/pairing-token", {
1502 body: yield* HttpBody.json({}),
1503 });
12781504 assert.equal(response.status, 401);
12791505 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
12801506 );
12811507
1282 it.effect("lists and revokes pairing links for owner sessions", () =>
1508 it.effect("lists and revokes pairing links for access management sessions", () =>
12831509 Effect.gen(function* () {
12841510 yield* buildAppUnderTest({
12851511 config: {
......@@ -1292,6 +1518,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
12921518 headers: {
12931519 cookie: ownerCookie,
12941520 },
1521 body: yield* HttpBody.json({}),
12951522 });
12961523 const createdBody = (yield* createdResponse.json) as {
12971524 readonly id: string;
......@@ -1324,7 +1551,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13241551 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
13251552 );
13261553
1327 it.effect("rejects pairing credential requests from non-owner paired sessions", () =>
1554 it.effect("rejects pairing credential requests without access management scope", () =>
13281555 Effect.gen(function* () {
13291556 yield* buildAppUnderTest({
13301557 config: {
......@@ -1336,6 +1563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13361563 headers: {
13371564 cookie: yield* getAuthenticatedSessionCookieHeader(),
13381565 },
1566 body: yield* HttpBody.json({}),
13391567 });
13401568 const ownerBody = (yield* ownerResponse.json) as {
13411569 readonly credential: string;
......@@ -1347,17 +1575,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13471575 headers: {
13481576 cookie: pairedSessionCookie,
13491577 },
1578 body: yield* HttpBody.json({}),
13501579 });
13511580 const pairedBody = (yield* pairedResponse.json) as {
1352 readonly error: string;
1581 readonly _tag: string;
1582 readonly code: string;
1583 readonly requiredScope: string;
1584 readonly traceId: string;
13531585 };
13541586
13551587 assert.equal(pairedResponse.status, 403);
1356 assert.equal(pairedBody.error, "Only owner sessions can create pairing credentials.");
1588 assert.equal(pairedBody._tag, "EnvironmentScopeRequiredError");
1589 assert.equal(pairedBody.code, "insufficient_scope");
1590 assert.equal(pairedBody.requiredScope, "access:write");
1591 assert.equal(typeof pairedBody.traceId, "string");
13571592 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
13581593 );
13591594
1360 it.effect("lists paired clients and revokes other sessions while keeping the owner", () =>
1595 it.effect("lists paired clients and revokes other sessions while keeping the administrator", () =>
13611596 Effect.gen(function* () {
13621597 yield* buildAppUnderTest({
13631598 config: {
......@@ -1429,9 +1664,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
14291664 headers: {
14301665 cookie: pairedSessionCookieHeader,
14311666 },
1667 body: yield* HttpBody.json({}),
14321668 });
14331669 const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as {
1434 readonly error: string;
1670 readonly _tag: string;
1671 readonly code: string;
1672 readonly reason: string;
1673 readonly traceId: string;
14351674 };
14361675
14371676 assert.equal(listBeforeResponse.status, 200);
......@@ -1452,7 +1691,69 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
14521691 assert.lengthOf(clientsAfter, 1);
14531692 assert.equal(clientsAfter[0]?.current, true);
14541693 assert.equal(pairedClientPairingResponse.status, 401);
1455 assert.equal(pairedClientPairingBody.error, "Unauthorized request.");
1694 assert.equal(pairedClientPairingBody._tag, "EnvironmentAuthInvalidError");
1695 assert.equal(pairedClientPairingBody.code, "auth_invalid");
1696 assert.equal(pairedClientPairingBody.reason, "invalid_credential");
1697 assert.equal(typeof pairedClientPairingBody.traceId, "string");
1698 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1699 );
1700
1701 it.effect("separates access inventory reads from credential management writes", () =>
1702 Effect.gen(function* () {
1703 yield* buildAppUnderTest({
1704 config: {
1705 host: "0.0.0.0",
1706 },
1707 });
1708
1709 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1710 const issueScopedSession = Effect.fnUntraced(function* (
1711 scope: "access:read" | "access:write",
1712 ) {
1713 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1714 headers: {
1715 cookie: ownerCookie,
1716 },
1717 body: yield* HttpBody.json({ scopes: [scope] }),
1718 });
1719 assert.equal(pairingResponse.status, 200);
1720 const pairingBody = (yield* pairingResponse.json) as {
1721 readonly credential: string;
1722 };
1723 return yield* getAuthenticatedSessionCookieHeader(pairingBody.credential);
1724 });
1725
1726 const readCookie = yield* issueScopedSession("access:read");
1727 const readListResponse = yield* HttpClient.get("/api/auth/clients", {
1728 headers: {
1729 cookie: readCookie,
1730 },
1731 });
1732 const readWriteResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1733 headers: {
1734 cookie: readCookie,
1735 },
1736 body: yield* HttpBody.json({}),
1737 });
1738 const readWriteBody = (yield* readWriteResponse.json) as {
1739 readonly requiredScope: string;
1740 };
1741
1742 const writeCookie = yield* issueScopedSession("access:write");
1743 const writeListResponse = yield* HttpClient.get("/api/auth/clients", {
1744 headers: {
1745 cookie: writeCookie,
1746 },
1747 });
1748 const writeListBody = (yield* writeListResponse.json) as {
1749 readonly requiredScope: string;
1750 };
1751
1752 assert.equal(readListResponse.status, 200);
1753 assert.equal(readWriteResponse.status, 403);
1754 assert.equal(readWriteBody.requiredScope, "access:write");
1755 assert.equal(writeListResponse.status, 403);
1756 assert.equal(writeListBody.requiredScope, "access:read");
14561757 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
14571758 );
14581759
......@@ -1469,6 +1770,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
14691770 headers: {
14701771 cookie: ownerCookie,
14711772 },
1773 body: yield* HttpBody.json({}),
14721774 });
14731775 const pairingBody = (yield* pairingResponse.json) as {
14741776 readonly credential: string;
......@@ -1499,6 +1801,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
14991801 headers: {
15001802 cookie: pairedSessionCookie,
15011803 },
1804 body: yield* HttpBody.json({}),
15021805 });
15031806
15041807 assert.equal(revokeResponse.status, 200);
......@@ -1515,10 +1818,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
15151818
15161819 assert.equal(first.response.status, 200);
15171820 assert.equal(second.response.status, 401);
1518 assert.equal(
1519 (second.body as { readonly error?: string }).error,
1520 "Invalid bootstrap credential.",
1521 );
1821 assert.equal((second.body as { readonly _tag?: string })._tag, "EnvironmentAuthInvalidError");
1822 assert.equal((second.body as { readonly code?: string }).code, "auth_invalid");
1823 assert.equal((second.body as { readonly reason?: string }).reason, "invalid_credential");
15221824 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
15231825 );
15241826
......@@ -1594,15 +1896,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
15941896 yield* buildAppUnderTest();
15951897
15961898 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1597 const wsTokenResponse = yield* HttpClient.post("/api/auth/ws-token", {
1899 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
15981900 headers: {
15991901 authorization: `Bearer ${bearerToken}`,
16001902 },
16011903 });
1602 const wsTokenBody = (yield* wsTokenResponse.json) as {
1603 readonly token: string;
1904 const wsTicketBody = (yield* wsTicketResponse.json) as {
1905 readonly ticket: string;
16041906 };
1605 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsToken=${encodeURIComponent(wsTokenBody.token)}`;
1907 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
16061908
16071909 const response = yield* Effect.scoped(
16081910 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})),
apps/server/src/server.ts+15-34
......@@ -1,13 +1,15 @@
1import { EnvironmentHttpApi } from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
23import * as Layer from "effect/Layer";
34import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
5import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
46
57import { ServerConfig } from "./config.ts";
68import {
79 attachmentsRouteLayer,
810 otlpTracesProxyRouteLayer,
911 projectFaviconRouteLayer,
10 serverEnvironmentRouteLayer,
12 serverEnvironmentHttpApiLayer,
1113 staticAndDevRouteLayer,
1214 browserApiCorsLayer,
1315} from "./http.ts";
......@@ -62,20 +64,9 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe
6264import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
6365import { ObservabilityLive } from "./observability/Layers/Observability.ts";
6466import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
65import {
66 authBearerBootstrapRouteLayer,
67 authBootstrapRouteLayer,
68 authClientsRevokeOthersRouteLayer,
69 authClientsRevokeRouteLayer,
70 authClientsRouteLayer,
71 authPairingLinksRevokeRouteLayer,
72 authPairingLinksRouteLayer,
73 authPairingCredentialRouteLayer,
74 authSessionRouteLayer,
75 authWebSocketTokenRouteLayer,
76} from "./auth/http.ts";
77import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
78import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
67import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
68import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
69import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
7970import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
8071import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
8172import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
......@@ -85,10 +76,7 @@ import {
8576 makePersistedServerRuntimeState,
8677 persistServerRuntimeState,
8778} from "./serverRuntimeState.ts";
88import {
89 orchestrationDispatchRouteLayer,
90 orchestrationSnapshotRouteLayer,
91} from "./orchestration/http.ts";
79import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
9280import * as NetService from "@t3tools/shared/Net";
9381import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
9482
......@@ -238,9 +226,9 @@ const WorkspaceLayerLive = Layer.mergeAll(
238226 WorkspaceFileSystemLayerLive,
239227);
240228
241const AuthLayerLive = ServerAuthLive.pipe(
229const AuthLayerLive = EnvironmentAuth.layer.pipe(
242230 Layer.provideMerge(PersistenceLayerLive),
243 Layer.provide(ServerSecretStoreLive),
231 Layer.provide(ServerSecretStore.layer),
244232);
245233
246234const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
......@@ -301,22 +289,15 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
301289);
302290
303291export const makeRoutesLayer = Layer.mergeAll(
304 authBearerBootstrapRouteLayer,
305 authBootstrapRouteLayer,
306 authClientsRevokeOthersRouteLayer,
307 authClientsRevokeRouteLayer,
308 authClientsRouteLayer,
309 authPairingLinksRevokeRouteLayer,
310 authPairingLinksRouteLayer,
311 authPairingCredentialRouteLayer,
312 authSessionRouteLayer,
313 authWebSocketTokenRouteLayer,
292 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
293 Layer.provide(authHttpApiLayer),
294 Layer.provide(orchestrationHttpApiLayer),
295 Layer.provide(serverEnvironmentHttpApiLayer),
296 Layer.provide(environmentAuthenticatedAuthLayer),
297 ),
314298 attachmentsRouteLayer,
315 orchestrationDispatchRouteLayer,
316 orchestrationSnapshotRouteLayer,
317299 otlpTracesProxyRouteLayer,
318300 projectFaviconRouteLayer,
319 serverEnvironmentRouteLayer,
320301 staticAndDevRouteLayer,
321302 websocketRpcRouteLayer,
322303).pipe(Layer.provide(browserApiCorsLayer));
apps/server/src/serverRuntimeStartup.ts+2-2
......@@ -32,7 +32,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts";
3232import { ServerSettingsService } from "./serverSettings.ts";
3333import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
3434import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
35import { ServerAuth } from "./auth/Services/ServerAuth.ts";
35import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3636import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts";
3737import {
3838 formatHeadlessServeOutput,
......@@ -244,7 +244,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
244244
245245const resolveStartupBrowserTarget = Effect.gen(function* () {
246246 const serverConfig = yield* ServerConfig;
247 const serverAuth = yield* ServerAuth;
247 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
248248 const localUrl = `http://localhost:${serverConfig.port}`;
249249 const bindUrl =
250250 serverConfig.host && !isWildcardHost(serverConfig.host)
apps/server/src/serverSettings.ts+3-4
......@@ -47,8 +47,7 @@ import { ServerConfig } from "./config.ts";
4747import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
4848import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
4949import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
50import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
51import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";
50import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
5251
5352const encodeServerSettings = Schema.encodeEffect(ServerSettings);
5453const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
......@@ -257,7 +256,7 @@ const makeServerSettings = Effect.gen(function* () {
257256 const { settingsPath } = yield* ServerConfig;
258257 const fs = yield* FileSystem.FileSystem;
259258 const pathService = yield* Path.Path;
260 const secretStore = yield* ServerSecretStore;
259 const secretStore = yield* ServerSecretStore.ServerSecretStore;
261260 const writeSemaphore = yield* Semaphore.make(1);
262261 const cacheKey = "settings" as const;
263262 const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
......@@ -580,5 +579,5 @@ const makeServerSettings = Effect.gen(function* () {
580579});
581580
582581export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings).pipe(
583 Layer.provide(ServerSecretStoreLive),
582 Layer.provide(ServerSecretStore.layer),
584583);
apps/server/src/startupAccess.ts+3-3
......@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";
55import { HttpServer } from "effect/unstable/http";
66
77import { ServerConfig } from "./config.ts";
8import { ServerAuth } from "./auth/Services/ServerAuth.ts";
8import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
99
1010export interface HeadlessServeAccessInfo {
1111 readonly connectionString: string;
......@@ -133,12 +133,12 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo):
133133export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () {
134134 const serverConfig = yield* ServerConfig;
135135 const httpServer = yield* HttpServer.HttpServer;
136 const serverAuth = yield* ServerAuth;
136 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
137137 const connectionString = resolveHeadlessConnectionString(
138138 serverConfig.host,
139139 resolveListeningPort(httpServer.address, serverConfig.port),
140140 );
141 const issued = yield* serverAuth.issuePairingCredential({ role: "owner" });
141 const issued = yield* serverAuth.issueStartupPairingCredential();
142142
143143 return {
144144 connectionString,
apps/server/src/ws.ts+163-28
......@@ -11,7 +11,14 @@ import * as Schema from "effect/Schema";
1111import * as Stream from "effect/Stream";
1212import {
1313 DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL,
14 AuthOrchestrationOperateScope,
15 AuthOrchestrationReadScope,
16 AuthReviewWriteScope,
17 AuthTerminalOperateScope,
18 AuthAccessReadScope,
19 AuthAccessStreamError,
1420 type AuthAccessStreamEvent,
21 type AuthEnvironmentScope,
1522 AuthSessionId,
1623 CommandId,
1724 EventId,
......@@ -29,6 +36,7 @@ import {
2936 ProjectWriteFileError,
3037 OrchestrationReplayEventsError,
3138 FilesystemBrowseError,
39 EnvironmentAuthorizationError,
3240 ThreadId,
3341 type TerminalAttachStreamEvent,
3442 type TerminalError,
......@@ -38,7 +46,7 @@ import {
3846 WsRpcGroup,
3947} from "@t3tools/contracts";
4048import { clamp } from "effect/Number";
41import { HttpRouter, HttpServerRequest } from "effect/unstable/http";
49import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
4250import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
4351
4452import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts";
......@@ -49,9 +57,9 @@ import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";
4957import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
5058import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
5159import {
52 observeRpcEffect,
53 observeRpcStream,
54 observeRpcStreamEffect,
60 observeRpcEffect as instrumentRpcEffect,
61 observeRpcStream as instrumentRpcStream,
62 observeRpcStreamEffect as instrumentRpcStreamEffect,
5563} from "./observability/RpcInstrumentation.ts";
5664import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
5765import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
......@@ -69,7 +77,8 @@ import { ReviewService } from "./review/ReviewService.ts";
6977import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts";
7078import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts";
7179import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
72import { ServerAuth } from "./auth/Services/ServerAuth.ts";
80import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
81import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts";
7382import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
7483import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
7584import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
......@@ -84,15 +93,9 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
8493import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
8594import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
8695import * as VcsProcess from "./vcs/VcsProcess.ts";
87import {
88 BootstrapCredentialService,
89 type BootstrapCredentialChange,
90} from "./auth/Services/BootstrapCredentialService.ts";
91import {
92 SessionCredentialService,
93 type SessionCredentialChange,
94} from "./auth/Services/SessionCredentialService.ts";
95import { respondToAuthError } from "./auth/http.ts";
96import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
97import * as SessionStore from "./auth/SessionStore.ts";
98import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
9699const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
97100const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);
98101
......@@ -122,8 +125,62 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract<
122125
123126const PROVIDER_STATUS_DEBOUNCE_MS = 200;
124127
128const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
129 [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope],
130 [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope],
131 [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope],
132 [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope],
133 [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],
134 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
135 [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
136 [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
137 [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
138 [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
139 [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
140 [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope],
141 [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope],
142 [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope],
143 [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope],
144 [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope],
145 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
146 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
147 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
148 [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],
149 [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope],
150 [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],
151 [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope],
152 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
153 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
154 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
155 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
156 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
157 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
158 [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope],
159 [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope],
160 [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope],
161 [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope],
162 [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope],
163 [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope],
164 [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope],
165 [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope],
166 [WS_METHODS.vcsInit, AuthOrchestrationOperateScope],
167 [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope],
168 [WS_METHODS.terminalOpen, AuthTerminalOperateScope],
169 [WS_METHODS.terminalAttach, AuthTerminalOperateScope],
170 [WS_METHODS.terminalWrite, AuthTerminalOperateScope],
171 [WS_METHODS.terminalResize, AuthTerminalOperateScope],
172 [WS_METHODS.terminalClear, AuthTerminalOperateScope],
173 [WS_METHODS.terminalRestart, AuthTerminalOperateScope],
174 [WS_METHODS.terminalClose, AuthTerminalOperateScope],
175 [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope],
176 [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope],
177 [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope],
178 [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope],
179 [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope],
180]);
181
125182function toAuthAccessStreamEvent(
126 change: BootstrapCredentialChange | SessionCredentialChange,
183 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,
127184 revision: number,
128185 currentSessionId: AuthSessionId,
129186): AuthAccessStreamEvent {
......@@ -162,9 +219,10 @@ function toAuthAccessStreamEvent(
162219 }
163220}
164221
165const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
222const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
166223 WsRpcGroup.toLayer(
167224 Effect.gen(function* () {
225 const currentSessionId = currentSession.sessionId;
168226 const crypto = yield* Crypto.Crypto;
169227 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
170228 const orchestrationEngine = yield* OrchestrationEngineService;
......@@ -187,7 +245,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
187245 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
188246 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
189247 const serverEnvironment = yield* ServerEnvironment;
190 const serverAuth = yield* ServerAuth;
248 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
191249 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;
192250 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
193251 Effect.map((settings) => settings.automaticGitFetchInterval),
......@@ -198,10 +256,70 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
198256 ),
199257 );
200258 const sourceControlRepositories = yield* SourceControlRepositoryService;
201 const bootstrapCredentials = yield* BootstrapCredentialService;
202 const sessions = yield* SessionCredentialService;
259 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
260 const sessions = yield* SessionStore.SessionStore;
203261 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
204262 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
263 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
264 new EnvironmentAuthorizationError({
265 message: `The authenticated token is missing required scope: ${requiredScope}.`,
266 requiredScope,
267 });
268 const authorizeEffect = <A, E, R>(
269 requiredScope: AuthEnvironmentScope,
270 effect: Effect.Effect<A, E, R>,
271 ): Effect.Effect<A, E | EnvironmentAuthorizationError, R> =>
272 currentSession.scopes.includes(requiredScope)
273 ? effect
274 : Effect.fail(authorizationError(requiredScope));
275 const authorizeStream = <A, E, R>(
276 requiredScope: AuthEnvironmentScope,
277 stream: Stream.Stream<A, E, R>,
278 ): Stream.Stream<A, E | EnvironmentAuthorizationError, R> =>
279 currentSession.scopes.includes(requiredScope)
280 ? stream
281 : Stream.fail(authorizationError(requiredScope));
282 const requiredScopeForMethod = (method: string): AuthEnvironmentScope => {
283 const requiredScope = RPC_REQUIRED_SCOPE.get(method);
284 if (requiredScope === undefined) {
285 throw new Error(`RPC method ${method} has no declared authorization scope.`);
286 }
287 return requiredScope;
288 };
289 const observeRpcEffect = <A, E, R>(
290 method: string,
291 effect: Effect.Effect<A, E, R>,
292 traceAttributes?: Readonly<Record<string, unknown>>,
293 ) =>
294 instrumentRpcEffect(
295 method,
296 authorizeEffect(requiredScopeForMethod(method), effect),
297 traceAttributes,
298 );
299 const observeRpcStream = <A, E, R>(
300 method: string,
301 stream: Stream.Stream<A, E, R>,
302 traceAttributes?: Readonly<Record<string, unknown>>,
303 ) =>
304 instrumentRpcStream(
305 method,
306 authorizeStream(requiredScopeForMethod(method), stream),
307 traceAttributes,
308 );
309 const observeRpcStreamEffect = <A, StreamError, StreamContext, EffectError, EffectContext>(
310 method: string,
311 effect: Effect.Effect<
312 Stream.Stream<A, StreamError, StreamContext>,
313 EffectError,
314 EffectContext
315 >,
316 traceAttributes?: Readonly<Record<string, unknown>>,
317 ) =>
318 instrumentRpcStreamEffect(
319 method,
320 authorizeEffect(requiredScopeForMethod(method), effect),
321 traceAttributes,
322 );
205323 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>
206324 isOrchestrationDispatchCommandError(cause)
207325 ? cause
......@@ -220,9 +338,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
220338
221339 const loadAuthAccessSnapshot = () =>
222340 Effect.all({
223 pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie),
224 clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie),
225 });
341 pairingLinks: serverAuth.listPairingLinks(),
342 clientSessions: serverAuth.listClientSessions(currentSessionId),
343 }).pipe(
344 Effect.mapError(
345 (error) =>
346 new AuthAccessStreamError({
347 message: error.message,
348 }),
349 ),
350 );
226351
227352 const appendSetupScriptActivity = (input: {
228353 readonly threadId: ThreadId;
......@@ -1258,7 +1383,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
12581383 const initialSnapshot = yield* loadAuthAccessSnapshot();
12591384 const revisionRef = yield* Ref.make(1);
12601385 const accessChanges: Stream.Stream<
1261 BootstrapCredentialChange | SessionCredentialChange
1386 PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange
12621387 > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges);
12631388
12641389 const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe(
......@@ -1294,14 +1419,19 @@ export const websocketRpcRouteLayer = Layer.unwrap(
12941419 "/ws",
12951420 Effect.gen(function* () {
12961421 const request = yield* HttpServerRequest.HttpServerRequest;
1297 const serverAuth = yield* ServerAuth;
1298 const sessions = yield* SessionCredentialService;
1299 const session = yield* serverAuth.authenticateWebSocketUpgrade(request);
1422 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
1423 const sessions = yield* SessionStore.SessionStore;
1424 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
1425 Effect.catchTags({
1426 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
1427 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
1428 }),
1429 );
13001430 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
13011431 disableTracing: true,
13021432 }).pipe(
13031433 Effect.provide(
1304 makeWsRpcLayer(session.sessionId).pipe(
1434 makeWsRpcLayer(session).pipe(
13051435 Layer.provideMerge(RpcSerialization.layerJson),
13061436 Layer.provide(ProviderMaintenanceRunner.layer),
13071437 Layer.provide(
......@@ -1333,7 +1463,12 @@ export const websocketRpcRouteLayer = Layer.unwrap(
13331463 () => rpcWebSocketHttpEffect,
13341464 () => sessions.markDisconnected(session.sessionId),
13351465 );
1336 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
1466 }).pipe(
1467 Effect.catchTags({
1468 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
1469 EnvironmentInternalError: HttpServerRespondable.toResponse,
1470 }),
1471 ),
13371472 ),
13381473 ),
13391474);
apps/web/package.json+2-1
......@@ -43,6 +43,7 @@
4343 "zustand": "^5.0.11"
4444 },
4545 "devDependencies": {
46 "@effect/platform-node": "catalog:",
4647 "@rolldown/plugin-babel": "^0.2.0",
4748 "@tailwindcss/vite": "^4.0.0",
4849 "@tanstack/router-plugin": "^1.161.0",
......@@ -51,7 +52,7 @@
5152 "@types/react-dom": "~19.2.3",
5253 "@vercel/config": "^0.3.0",
5354 "@vitejs/plugin-react": "^6.0.0",
54 "@vitest/browser-playwright": "^4.0.18",
55 "@vitest/browser-playwright": "catalog:",
5556 "babel-plugin-react-compiler": "1.0.0",
5657 "msw": "2.12.11",
5758 "playwright": "^1.58.2",
apps/web/src/authBootstrap.test.ts+174-316
......@@ -1,15 +1,17 @@
1import type { DesktopBridge } from "@t3tools/contracts";
1import {
2 AuthSessionState as AuthSessionStateSchema,
3 EnvironmentAuthInvalidError,
4 type AuthBrowserSessionResult,
5 type AuthCreatePairingCredentialInput,
6 type AuthSessionState,
7 type DesktopBridge,
8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
212import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
313
4function jsonResponse(body: unknown, init?: ResponseInit) {
5 return new Response(JSON.stringify(body), {
6 headers: {
7 "content-type": "application/json",
8 },
9 status: 200,
10 ...init,
11 });
12}
14import { installEnvironmentHttpTest } from "../test/environmentHttpTest";
1315
1416type TestWindow = {
1517 location: URL;
......@@ -19,6 +21,42 @@ type TestWindow = {
1921 desktopBridge?: DesktopBridge;
2022};
2123
24const LOOPBACK_AUTH = {
25 policy: "loopback-browser",
26 bootstrapMethods: ["one-time-token"],
27 sessionMethods: ["browser-session-cookie"],
28 sessionCookieName: "t3_session",
29} as const;
30
31const DESKTOP_AUTH = {
32 policy: "desktop-managed-local",
33 bootstrapMethods: ["desktop-bootstrap"],
34 sessionMethods: ["browser-session-cookie"],
35 sessionCookieName: "t3_session",
36} as const;
37
38const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z");
39const encodeAuthSessionState = Schema.encodeSync(AuthSessionStateSchema);
40
41const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({
42 authenticated: false,
43 auth,
44});
45
46const authenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({
47 authenticated: true,
48 auth,
49 sessionMethod: "browser-session-cookie",
50 expiresAt: SESSION_EXPIRES_AT,
51});
52
53const browserSession = (scopes: AuthBrowserSessionResult["scopes"]): AuthBrowserSessionResult => ({
54 authenticated: true,
55 scopes,
56 sessionMethod: "browser-session-cookie",
57 expiresAt: SESSION_EXPIRES_AT,
58});
59
2260function installTestBrowser(url: string) {
2361 const testWindow: TestWindow = {
2462 location: new URL(url),
......@@ -35,8 +73,36 @@ function installTestBrowser(url: string) {
3573 return testWindow;
3674}
3775
38function sessionResponse(body: unknown, init?: ResponseInit) {
39 return jsonResponse(body, init);
76function sequence<A>(...values: ReadonlyArray<A>) {
77 let index = 0;
78 return () => values[Math.min(index++, values.length - 1)]!;
79}
80
81let disposeHttpTest: (() => Promise<void>) | undefined;
82
83async function installAuthApi(input: {
84 readonly session?: () => AuthSessionState;
85 readonly browserSession?: (
86 credential: string,
87 ) => Effect.Effect<AuthBrowserSessionResult, EnvironmentAuthInvalidError>;
88 readonly pairingCredential?: (payload: AuthCreatePairingCredentialInput) => Effect.Effect<{
89 readonly id: string;
90 readonly credential: string;
91 readonly label?: string;
92 readonly expiresAt: DateTime.Utc;
93 }>;
94}) {
95 const testApi = await installEnvironmentHttpTest({
96 ...(input.session ? { session: () => Effect.succeed(input.session!()) } : {}),
97 ...(input.browserSession
98 ? { browserSession: (payload) => input.browserSession!(payload.credential) }
99 : {}),
100 ...(input.pairingCredential
101 ? { pairingCredential: (payload) => input.pairingCredential!(payload) }
102 : {}),
103 });
104 disposeHttpTest = testApi.dispose;
105 return testApi;
40106}
41107
42108describe("resolveInitialServerAuthGateState", () => {
......@@ -47,6 +113,8 @@ describe("resolveInitialServerAuthGateState", () => {
47113 });
48114
49115 afterEach(async () => {
116 await disposeHttpTest?.();
117 disposeHttpTest = undefined;
50118 const { __resetServerAuthBootstrapForTests } = await import("./environments/primary");
51119 __resetServerAuthBootstrapForTests();
52120 vi.unstubAllEnvs();
......@@ -55,40 +123,14 @@ describe("resolveInitialServerAuthGateState", () => {
55123 });
56124
57125 it("reuses an in-flight silent bootstrap attempt", async () => {
58 const fetchMock = vi
59 .fn<typeof fetch>()
60 .mockResolvedValueOnce(
61 sessionResponse({
62 authenticated: false,
63 auth: {
64 policy: "desktop-managed-local",
65 bootstrapMethods: ["desktop-bootstrap"],
66 sessionMethods: ["browser-session-cookie"],
67 sessionCookieName: "t3_session",
68 },
69 }),
70 )
71 .mockResolvedValueOnce(
72 jsonResponse({
73 authenticated: true,
74 sessionMethod: "browser-session-cookie",
75 expiresAt: "2026-04-05T00:00:00.000Z",
76 }),
77 )
78 .mockResolvedValueOnce(
79 sessionResponse({
80 authenticated: true,
81 auth: {
82 policy: "loopback-browser",
83 bootstrapMethods: ["one-time-token"],
84 sessionMethods: ["browser-session-cookie"],
85 sessionCookieName: "t3_session",
86 },
87 sessionMethod: "browser-session-cookie",
88 expiresAt: "2026-04-05T00:00:00.000Z",
89 }),
90 );
91 vi.stubGlobal("fetch", fetchMock);
126 const nextSession = sequence(
127 unauthenticatedSession(DESKTOP_AUTH),
128 authenticatedSession(DESKTOP_AUTH),
129 );
130 const testApi = await installAuthApi({
131 session: nextSession,
132 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
133 });
92134
93135 const testWindow = installTestBrowser("http://localhost/");
94136 testWindow.desktopBridge = {
......@@ -104,90 +146,45 @@ describe("resolveInitialServerAuthGateState", () => {
104146
105147 await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]);
106148
107 expect(fetchMock).toHaveBeenCalledTimes(3);
108 expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3773/api/auth/session");
109 expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3773/api/auth/bootstrap");
110 expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session");
149 expect(testApi.calls.session).toBe(2);
150 expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]);
111151 });
112152
113 it("uses https fetch urls when the primary environment uses wss", async () => {
114 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
115 sessionResponse({
116 authenticated: false,
117 auth: {
118 policy: "loopback-browser",
119 bootstrapMethods: ["one-time-token"],
120 sessionMethods: ["browser-session-cookie"],
121 sessionCookieName: "t3_session",
122 },
123 }),
124 );
125 vi.stubGlobal("fetch", fetchMock);
153 it("uses https urls when the primary environment uses wss", async () => {
154 await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) });
126155 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
127156 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
128157
129 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
158 const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } =
159 await import("./environments/primary");
130160
131161 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
132162 status: "requires-auth",
133 auth: {
134 policy: "loopback-browser",
135 bootstrapMethods: ["one-time-token"],
136 sessionMethods: ["browser-session-cookie"],
137 sessionCookieName: "t3_session",
138 },
139 });
140
141 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", {
142 credentials: "include",
163 auth: LOOPBACK_AUTH,
143164 });
165 expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe(
166 "https://remote.example.com/api/auth/session",
167 );
144168 });
145169
146170 it("uses the current origin as an auth proxy base for local dev environments", async () => {
147 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
148 sessionResponse({
149 authenticated: false,
150 auth: {
151 policy: "loopback-browser",
152 bootstrapMethods: ["one-time-token"],
153 sessionMethods: ["browser-session-cookie"],
154 sessionCookieName: "t3_session",
155 },
156 }),
157 );
158 vi.stubGlobal("fetch", fetchMock);
171 await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) });
159172 installTestBrowser("http://localhost:5735/");
160173
161 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
174 const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } =
175 await import("./environments/primary");
162176
163177 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
164178 status: "requires-auth",
165 auth: {
166 policy: "loopback-browser",
167 bootstrapMethods: ["one-time-token"],
168 sessionMethods: ["browser-session-cookie"],
169 sessionCookieName: "t3_session",
170 },
171 });
172
173 expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", {
174 credentials: "include",
179 auth: LOOPBACK_AUTH,
175180 });
181 expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe(
182 "http://localhost:5735/api/auth/session",
183 );
176184 });
177185
178186 it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => {
179 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
180 sessionResponse({
181 authenticated: false,
182 auth: {
183 policy: "desktop-managed-local",
184 bootstrapMethods: ["desktop-bootstrap"],
185 sessionMethods: ["browser-session-cookie"],
186 sessionCookieName: "t3_session",
187 },
188 }),
189 );
190 vi.stubGlobal("fetch", fetchMock);
187 await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) });
191188 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");
192189
193190 const testWindow = installTestBrowser("http://127.0.0.1:5733/");
......@@ -199,47 +196,25 @@ describe("resolveInitialServerAuthGateState", () => {
199196 }),
200197 } as DesktopBridge;
201198
202 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
199 const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } =
200 await import("./environments/primary");
203201
204202 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
205203 status: "requires-auth",
206 auth: {
207 policy: "desktop-managed-local",
208 bootstrapMethods: ["desktop-bootstrap"],
209 sessionMethods: ["browser-session-cookie"],
210 sessionCookieName: "t3_session",
211 },
212 });
213
214 expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/api/auth/session", {
215 credentials: "include",
204 auth: DESKTOP_AUTH,
216205 });
206 expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe(
207 "http://127.0.0.1:5733/api/auth/session",
208 );
217209 });
218210
219211 it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => {
220 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
221 sessionResponse({
222 authenticated: false,
223 auth: {
224 policy: "loopback-browser",
225 bootstrapMethods: ["one-time-token"],
226 sessionMethods: ["browser-session-cookie"],
227 sessionCookieName: "t3_session",
228 },
229 }),
230 );
231 vi.stubGlobal("fetch", fetchMock);
232
212 await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) });
233213 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
234214
235215 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
236216 status: "requires-auth",
237 auth: {
238 policy: "loopback-browser",
239 bootstrapMethods: ["one-time-token"],
240 sessionMethods: ["browser-session-cookie"],
241 sessionCookieName: "t3_session",
242 },
217 auth: LOOPBACK_AUTH,
243218 });
244219 });
245220
......@@ -251,15 +226,10 @@ describe("resolveInitialServerAuthGateState", () => {
251226 .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 }))
252227 .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 }))
253228 .mockResolvedValueOnce(
254 sessionResponse({
255 authenticated: false,
256 auth: {
257 policy: "loopback-browser",
258 bootstrapMethods: ["one-time-token"],
259 sessionMethods: ["browser-session-cookie"],
260 sessionCookieName: "t3_session",
261 },
262 }),
229 new Response(
230 JSON.stringify(encodeAuthSessionState(unauthenticatedSession(LOOPBACK_AUTH))),
231 { status: 200, headers: { "content-type": "application/json" } },
232 ),
263233 );
264234 vi.stubGlobal("fetch", fetchMock);
265235
......@@ -270,12 +240,7 @@ describe("resolveInitialServerAuthGateState", () => {
270240
271241 await expect(gateStatePromise).resolves.toEqual({
272242 status: "requires-auth",
273 auth: {
274 policy: "loopback-browser",
275 bootstrapMethods: ["one-time-token"],
276 sessionMethods: ["browser-session-cookie"],
277 sessionCookieName: "t3_session",
278 },
243 auth: LOOPBACK_AUTH,
279244 });
280245 expect(fetchMock).toHaveBeenCalledTimes(4);
281246 });
......@@ -298,136 +263,60 @@ describe("resolveInitialServerAuthGateState", () => {
298263 });
299264
300265 it("allows manual token submission after the initial auth check requires pairing", async () => {
301 const fetchMock = vi
302 .fn<typeof fetch>()
303 .mockResolvedValueOnce(
304 sessionResponse({
305 authenticated: false,
306 auth: {
307 policy: "loopback-browser",
308 bootstrapMethods: ["one-time-token"],
309 sessionMethods: ["browser-session-cookie"],
310 sessionCookieName: "t3_session",
311 },
312 }),
313 )
314 .mockResolvedValueOnce(
315 jsonResponse({
316 authenticated: true,
317 sessionMethod: "browser-session-cookie",
318 expiresAt: "2026-04-05T00:00:00.000Z",
319 }),
320 )
321 .mockResolvedValueOnce(
322 sessionResponse({
323 authenticated: true,
324 auth: {
325 policy: "loopback-browser",
326 bootstrapMethods: ["one-time-token"],
327 sessionMethods: ["browser-session-cookie"],
328 sessionCookieName: "t3_session",
329 },
330 sessionMethod: "browser-session-cookie",
331 expiresAt: "2026-04-05T00:00:00.000Z",
332 }),
333 );
334 vi.stubGlobal("fetch", fetchMock);
335 installTestBrowser("http://localhost/");
336
266 const nextSession = sequence(
267 unauthenticatedSession(LOOPBACK_AUTH),
268 authenticatedSession(LOOPBACK_AUTH),
269 );
270 const testApi = await installAuthApi({
271 session: nextSession,
272 browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
273 });
337274 const { resolveInitialServerAuthGateState, submitServerAuthCredential } =
338275 await import("./environments/primary");
339276
340277 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
341278 status: "requires-auth",
342 auth: {
343 policy: "loopback-browser",
344 bootstrapMethods: ["one-time-token"],
345 sessionMethods: ["browser-session-cookie"],
346 sessionCookieName: "t3_session",
347 },
279 auth: LOOPBACK_AUTH,
348280 });
349281 await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined();
350282 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
351283 status: "authenticated",
352284 });
353 expect(fetchMock).toHaveBeenCalledTimes(3);
285 expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]);
286 expect(testApi.calls.session).toBe(2);
354287 });
355288
356289 it("surfaces a friendly error message when an invalid pairing token is submitted", async () => {
357 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
358 jsonResponse(
359 {
360 error: "Invalid bootstrap credential.",
361 },
362 {
363 status: 401,
364 },
365 ),
366 );
367 vi.stubGlobal("fetch", fetchMock);
290 const testApi = await installAuthApi({
291 browserSession: () =>
292 Effect.fail(
293 new EnvironmentAuthInvalidError({
294 code: "auth_invalid",
295 reason: "invalid_credential",
296 traceId: "trace-invalid-credential",
297 }),
298 ),
299 });
368300
369301 const { submitServerAuthCredential } = await import("./environments/primary");
370302
371303 await expect(submitServerAuthCredential("bad-token")).rejects.toThrow(
372304 "Invalid pairing token. Check the token and try again.",
373305 );
374 expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/bootstrap", {
375 body: JSON.stringify({ credential: "bad-token" }),
376 credentials: "include",
377 headers: {
378 "content-type": "application/json",
379 },
380 method: "POST",
381 });
306 expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]);
382307 });
383308
384309 it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => {
385310 vi.useFakeTimers();
386 const fetchMock = vi
387 .fn<typeof fetch>()
388 .mockResolvedValueOnce(
389 sessionResponse({
390 authenticated: false,
391 auth: {
392 policy: "desktop-managed-local",
393 bootstrapMethods: ["desktop-bootstrap"],
394 sessionMethods: ["browser-session-cookie"],
395 sessionCookieName: "t3_session",
396 },
397 }),
398 )
399 .mockResolvedValueOnce(
400 jsonResponse({
401 authenticated: true,
402 sessionMethod: "browser-session-cookie",
403 expiresAt: "2026-04-05T00:00:00.000Z",
404 }),
405 )
406 .mockResolvedValueOnce(
407 sessionResponse({
408 authenticated: false,
409 auth: {
410 policy: "desktop-managed-local",
411 bootstrapMethods: ["desktop-bootstrap"],
412 sessionMethods: ["browser-session-cookie"],
413 sessionCookieName: "t3_session",
414 },
415 }),
416 )
417 .mockResolvedValueOnce(
418 sessionResponse({
419 authenticated: true,
420 auth: {
421 policy: "desktop-managed-local",
422 bootstrapMethods: ["desktop-bootstrap"],
423 sessionMethods: ["browser-session-cookie"],
424 sessionCookieName: "t3_session",
425 },
426 sessionMethod: "browser-session-cookie",
427 expiresAt: "2026-04-05T00:00:00.000Z",
428 }),
429 );
430 vi.stubGlobal("fetch", fetchMock);
311 const nextSession = sequence(
312 unauthenticatedSession(DESKTOP_AUTH),
313 unauthenticatedSession(DESKTOP_AUTH),
314 authenticatedSession(DESKTOP_AUTH),
315 );
316 const testApi = await installAuthApi({
317 session: nextSession,
318 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
319 });
431320
432321 const testWindow = installTestBrowser("http://localhost/");
433322 testWindow.desktopBridge = {
......@@ -444,43 +333,14 @@ describe("resolveInitialServerAuthGateState", () => {
444333 const gateStatePromise = resolveInitialServerAuthGateState();
445334 await vi.advanceTimersByTimeAsync(100);
446335
447 await expect(gateStatePromise).resolves.toEqual({
448 status: "authenticated",
449 });
450 expect(fetchMock).toHaveBeenCalledTimes(4);
451 expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session");
452 expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session");
336 await expect(gateStatePromise).resolves.toEqual({ status: "authenticated" });
337 expect(testApi.calls.session).toBe(3);
453338 });
454339
455340 it("memoizes the authenticated gate state after the first successful read", async () => {
456 const fetchMock = vi
457 .fn<typeof fetch>()
458 .mockResolvedValueOnce(
459 sessionResponse({
460 authenticated: true,
461 auth: {
462 policy: "loopback-browser",
463 bootstrapMethods: ["one-time-token"],
464 sessionMethods: ["browser-session-cookie"],
465 sessionCookieName: "t3_session",
466 },
467 sessionMethod: "browser-session-cookie",
468 expiresAt: "2026-04-05T00:00:00.000Z",
469 }),
470 )
471 .mockResolvedValueOnce(
472 sessionResponse({
473 authenticated: false,
474 auth: {
475 policy: "loopback-browser",
476 bootstrapMethods: ["one-time-token"],
477 sessionMethods: ["browser-session-cookie"],
478 sessionCookieName: "t3_session",
479 },
480 }),
481 );
482 vi.stubGlobal("fetch", fetchMock);
483
341 const testApi = await installAuthApi({
342 session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)),
343 });
484344 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
485345
486346 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
......@@ -489,35 +349,33 @@ describe("resolveInitialServerAuthGateState", () => {
489349 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
490350 status: "authenticated",
491351 });
492 expect(fetchMock).toHaveBeenCalledTimes(1);
352 expect(testApi.calls.session).toBe(1);
493353 });
494354
495355 it("creates a pairing credential from the authenticated auth endpoint", async () => {
496 const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
497 jsonResponse({
498 id: "pairing-link-1",
499 credential: "pairing-token",
500 label: "Julius iPhone",
501 expiresAt: "2026-04-05T00:00:00.000Z",
502 }),
503 );
504 vi.stubGlobal("fetch", fetchMock);
505
356 const testApi = await installAuthApi({
357 pairingCredential: (payload) =>
358 Effect.succeed({
359 id: "pairing-link-1",
360 credential: "pairing-token",
361 ...(payload.label === undefined ? {} : { label: payload.label }),
362 expiresAt: SESSION_EXPIRES_AT,
363 }),
364 });
506365 const { createServerPairingCredential } = await import("./environments/primary");
507366
508 await expect(createServerPairingCredential("Julius iPhone")).resolves.toEqual({
367 const credential = await createServerPairingCredential({
368 label: "Julius iPhone",
369 scopes: ["orchestration:read"],
370 });
371 expect(credential).toMatchObject({
509372 id: "pairing-link-1",
510373 credential: "pairing-token",
511374 label: "Julius iPhone",
512 expiresAt: "2026-04-05T00:00:00.000Z",
513 });
514 expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/pairing-token", {
515 body: JSON.stringify({ label: "Julius iPhone" }),
516 credentials: "include",
517 headers: {
518 "content-type": "application/json",
519 },
520 method: "POST",
521375 });
376 expect(DateTime.formatIso(credential.expiresAt)).toBe("2026-04-05T00:00:00.000Z");
377 expect(testApi.calls.pairingCredential).toEqual([
378 { label: "Julius iPhone", scopes: ["orchestration:read"] },
379 ]);
522380 });
523381});
apps/web/src/components/ChatView.browser.tsx+1-1
......@@ -194,7 +194,7 @@ function createBaseServerConfig(): ServerConfig {
194194 auth: {
195195 policy: "loopback-browser",
196196 bootstrapMethods: ["one-time-token"],
197 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
197 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
198198 sessionCookieName: "t3_session",
199199 },
200200 cwd: "/repo/project",
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
......@@ -96,7 +96,7 @@ function createBaseServerConfig(): ServerConfig {
9696 auth: {
9797 policy: "loopback-browser",
9898 bootstrapMethods: ["one-time-token"],
99 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
99 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
100100 sessionCookieName: "t3_session",
101101 },
102102 cwd: "/repo/project",
apps/web/src/components/settings/ConnectionsSettings.tsx+209-52
......@@ -9,7 +9,18 @@ import {
99} from "lucide-react";
1010import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
1111import {
12 AuthAccessReadScope,
13 AuthAccessWriteScope,
14 AuthAdministrativeScopes,
15 AuthOrchestrationOperateScope,
16 AuthOrchestrationReadScope,
17 AuthRelayReadScope,
18 AuthRelayWriteScope,
19 AuthReviewWriteScope,
20 AuthStandardClientScopes,
21 AuthTerminalOperateScope,
1222 type AuthClientSession,
23 type AuthEnvironmentScope,
1324 type AuthPairingLink,
1425 type AdvertisedEndpoint,
1526 type DesktopDiscoveredSshHost,
......@@ -31,6 +42,7 @@ import {
3142 useRelativeTimeTick,
3243} from "./settingsLayout";
3344import { Input } from "../ui/input";
45import { Checkbox } from "../ui/checkbox";
3446import {
3547 Dialog,
3648 DialogClose,
......@@ -75,11 +87,11 @@ import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
7587import { readHostedPairingRequest } from "../../hostedPairing";
7688import {
7789 createServerPairingCredential,
78 fetchSessionState,
7990 revokeOtherServerClientSessions,
8091 revokeServerClientSession,
8192 revokeServerPairingLink,
8293 isLoopbackHostname,
94 usePrimarySessionState,
8395 type ServerClientSessionRecord,
8496 type ServerPairingLinkRecord,
8597} from "~/environments/primary";
......@@ -114,6 +126,97 @@ function formatAccessTimestamp(value: string): string {
114126 return accessTimestampFormatter.format(parsed);
115127}
116128
129const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
130 readonly scope: AuthEnvironmentScope;
131 readonly title: string;
132 readonly description: string;
133}> = [
134 {
135 scope: AuthOrchestrationReadScope,
136 title: "View environment",
137 description: "Read threads, status, diffs, and configuration.",
138 },
139 {
140 scope: AuthOrchestrationOperateScope,
141 title: "Operate tasks",
142 description: "Start tasks and perform changes in the environment.",
143 },
144 {
145 scope: AuthTerminalOperateScope,
146 title: "Use terminals",
147 description: "Create terminals and send input to running shells.",
148 },
149 {
150 scope: AuthReviewWriteScope,
151 title: "Write reviews",
152 description: "Create comments while reviewing changes.",
153 },
154 {
155 scope: AuthAccessReadScope,
156 title: "View access",
157 description: "Inspect pairing links and authorized clients.",
158 },
159 {
160 scope: AuthAccessWriteScope,
161 title: "Manage access",
162 description: "Issue and revoke credentials for other clients.",
163 },
164 {
165 scope: AuthRelayReadScope,
166 title: "View relay",
167 description: "Inspect managed relay connectivity.",
168 },
169 {
170 scope: AuthRelayWriteScope,
171 title: "Manage relay",
172 description: "Change managed tunnel connectivity.",
173 },
174];
175
176function AccessScopeSummary({
177 scopes,
178 label,
179}: {
180 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
181 readonly label: string;
182}) {
183 const scopeCountLabel = `${scopes.length} ${scopes.length === 1 ? "scope" : "scopes"}`;
184
185 return (
186 <Popover>
187 <PopoverTrigger
188 openOnHover
189 delay={250}
190 closeDelay={100}
191 render={
192 <button
193 type="button"
194 aria-label={`${label}: show ${scopeCountLabel}`}
195 className="cursor-help underline decoration-border underline-offset-2 outline-hidden hover:text-foreground focus-visible:text-foreground"
196 />
197 }
198 >
199 {scopeCountLabel}
200 </PopoverTrigger>
201 <PopoverPopup
202 side="top"
203 align="start"
204 tooltipStyle
205 className="w-max max-w-80 whitespace-normal"
206 >
207 <p className="mb-1 font-medium">Granted scopes</p>
208 <div className="flex flex-col gap-0.5">
209 {scopes.map((scope) => (
210 <code key={scope} className="font-mono text-foreground/85">
211 {scope}
212 </code>
213 ))}
214 </div>
215 </PopoverPopup>
216 </Popover>
217 );
218}
219
117220type ConnectionStatusDotProps = {
118221 tooltipText?: string | null;
119222 dotClassName: string;
......@@ -629,8 +732,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
629732
630733 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
631734
632 const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client";
633 const primaryLabel = pairingLink.label ?? `${roleLabel} link`;
735 const primaryLabel = pairingLink.label ?? "Pairing link";
634736 const defaultEndpointCopyOption =
635737 endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ??
636738 endpointCopyOptions[0] ??
......@@ -759,7 +861,9 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
759861 </Popover>
760862 </div>
761863 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>
762 {[roleLabel, formatExpiresInLabel(pairingLink.expiresAt, nowMs)].join(" · ")}
864 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
865 <span aria-hidden> · </span>
866 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
763867 </p>
764868 {shareablePairingUrl === null ? (
765869 <p className="text-[11px] text-muted-foreground/70">
......@@ -900,7 +1004,6 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
9001004 : lastConnectedAt
9011005 ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}`
9021006 : "Not connected yet.";
903 const roleLabel = clientSession.role === "owner" ? "Owner" : "Client";
9041007 const deviceInfoBits = [
9051008 clientSession.client.deviceType !== "unknown"
9061009 ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1)
......@@ -932,7 +1035,13 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
9321035 ) : null}
9331036 </div>
9341037 <p className="text-xs text-muted-foreground">
935 {[roleLabel, ...deviceInfoBits].join(" · ")}
1038 {deviceInfoBits.length > 0 ? (
1039 <>
1040 {deviceInfoBits.join(" · ")}
1041 <span aria-hidden> · </span>
1042 </>
1043 ) : null}
1044 <AccessScopeSummary scopes={clientSession.scopes} label="Client scopes" />
9361045 </p>
9371046 </div>
9381047 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
......@@ -965,13 +1074,17 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
9651074}: AuthorizedClientsHeaderActionProps) {
9661075 const [dialogOpen, setDialogOpen] = useState(false);
9671076 const [pairingLabel, setPairingLabel] = useState("");
1077 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([
1078 ...AuthStandardClientScopes,
1079 ]);
9681080 const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false);
9691081
9701082 const handleCreatePairingLink = useCallback(async () => {
9711083 setIsCreatingPairingLink(true);
9721084 try {
973 await createServerPairingCredential(pairingLabel);
1085 await createServerPairingCredential({ label: pairingLabel, scopes: pairingScopes });
9741086 setPairingLabel("");
1087 setPairingScopes([...AuthStandardClientScopes]);
9751088 setDialogOpen(false);
9761089 } catch (error) {
9771090 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";
......@@ -985,7 +1098,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
9851098 } finally {
9861099 setIsCreatingPairingLink(false);
9871100 }
988 }, [pairingLabel]);
1101 }, [pairingLabel, pairingScopes]);
1102
1103 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
1104 setPairingScopes((current) =>
1105 checked ? [...current, scope] : current.filter((currentScope) => currentScope !== scope),
1106 );
1107 }, []);
9891108
9901109 return (
9911110 <div className="flex items-center gap-2">
......@@ -1005,6 +1124,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10051124 setDialogOpen(open);
10061125 if (!open) {
10071126 setPairingLabel("");
1127 setPairingScopes([...AuthStandardClientScopes]);
10081128 }
10091129 }}
10101130 >
......@@ -1016,7 +1136,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10161136 </Button>
10171137 }
10181138 />
1019 <DialogPopup className="max-w-sm">
1139 <DialogPopup className="max-w-md">
10201140 <DialogHeader>
10211141 <DialogTitle>Create pairing link</DialogTitle>
10221142 <DialogDescription>
......@@ -1024,7 +1144,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10241144 authorized client.
10251145 </DialogDescription>
10261146 </DialogHeader>
1027 <DialogPanel>
1147 <DialogPanel className="space-y-5">
10281148 <label className="block">
10291149 <span className="mb-1.5 block text-xs font-medium text-foreground">
10301150 Client label (optional)
......@@ -1037,6 +1157,62 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10371157 autoFocus
10381158 />
10391159 </label>
1160 <section className="space-y-3">
1161 <div className="flex items-center justify-between gap-3">
1162 <div>
1163 <h3 className="text-xs font-medium text-foreground">Permissions</h3>
1164 <p className="text-xs text-muted-foreground">
1165 Limit what the paired client can do.
1166 </p>
1167 </div>
1168 <div className="flex gap-1">
1169 <Button
1170 size="xs"
1171 variant="outline"
1172 disabled={isCreatingPairingLink}
1173 onClick={() => setPairingScopes([AuthOrchestrationReadScope])}
1174 >
1175 Read only
1176 </Button>
1177 <Button
1178 size="xs"
1179 variant="outline"
1180 disabled={isCreatingPairingLink}
1181 onClick={() => setPairingScopes([...AuthStandardClientScopes])}
1182 >
1183 Standard
1184 </Button>
1185 </div>
1186 </div>
1187 <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25">
1188 {PAIRING_SCOPE_OPTIONS.map(({ scope, title, description }) => (
1189 <label
1190 key={scope}
1191 className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
1192 >
1193 <Checkbox
1194 className="mt-0.5"
1195 checked={pairingScopes.includes(scope)}
1196 disabled={isCreatingPairingLink}
1197 onCheckedChange={(checked) => togglePairingScope(scope, checked === true)}
1198 />
1199 <span className="min-w-0">
1200 <span className="block text-xs font-medium text-foreground">{title}</span>
1201 <span className="block text-xs leading-snug text-muted-foreground">
1202 {description}
1203 </span>
1204 </span>
1205 </label>
1206 ))}
1207 </div>
1208 {pairingScopes.length === 0 ? (
1209 <p className="text-xs text-destructive">Select at least one permission.</p>
1210 ) : pairingScopes.includes(AuthAccessWriteScope) ? (
1211 <p className="text-xs text-warning">
1212 This client can create or revoke access for other devices.
1213 </p>
1214 ) : null}
1215 </section>
10401216 </DialogPanel>
10411217 <DialogFooter variant="bare">
10421218 <Button
......@@ -1046,7 +1222,10 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10461222 >
10471223 Cancel
10481224 </Button>
1049 <Button disabled={isCreatingPairingLink} onClick={() => void handleCreatePairingLink()}>
1225 <Button
1226 disabled={isCreatingPairingLink || pairingScopes.length === 0}
1227 onClick={() => void handleCreatePairingLink()}
1228 >
10501229 {isCreatingPairingLink ? "Creating…" : "Create link"}
10511230 </Button>
10521231 </DialogFooter>
......@@ -1290,14 +1469,12 @@ function SavedBackendListRow({
12901469 : connectionState === "error"
12911470 ? "bg-destructive"
12921471 : "bg-muted-foreground/40";
1293 const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null;
12941472 const descriptorLabel = runtime?.descriptor?.label ?? null;
12951473 const displayLabel = descriptorLabel ?? record.label;
12961474 const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs);
12971475 const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig);
12981476 const metadataBits = [
12991477 record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null,
1300 roleLabel,
13011478 record.lastConnectedAt
13021479 ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}`
13031480 : null,
......@@ -1317,8 +1494,14 @@ function SavedBackendListRow({
13171494 />
13181495 <h3 className="text-sm font-medium text-foreground">{displayLabel}</h3>
13191496 </div>
1320 {metadataBits.length > 0 ? (
1321 <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
1497 {metadataBits.length > 0 || runtime?.scopes ? (
1498 <p className="text-xs text-muted-foreground">
1499 {metadataBits.length > 0 ? metadataBits.join(" · ") : null}
1500 {metadataBits.length > 0 && runtime?.scopes ? <span aria-hidden> · </span> : null}
1501 {runtime?.scopes ? (
1502 <AccessScopeSummary scopes={runtime.scopes} label="Granted scopes" />
1503 ) : null}
1504 </p>
13221505 ) : null}
13231506 {versionMismatch ? (
13241507 <p className="flex items-center gap-1 text-warning text-xs">
......@@ -1401,12 +1584,13 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({
14011584
14021585export function ConnectionsSettings() {
14031586 const desktopBridge = window.desktopBridge;
1404 const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>(
1405 desktopBridge ? "owner" : null,
1406 );
1407 const [currentAuthPolicy, setCurrentAuthPolicy] = useState<
1408 "desktop-managed-local" | "loopback-browser" | "remote-reachable" | "unsafe-no-auth" | null
1409 >(desktopBridge ? null : null);
1587 const primarySessionState = usePrimarySessionState();
1588 const currentSessionScopes = desktopBridge
1589 ? AuthAdministrativeScopes
1590 : primarySessionState.data?.authenticated
1591 ? (primarySessionState.data.scopes ?? null)
1592 : null;
1593 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
14101594 const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId);
14111595 const savedEnvironmentIds = useMemo(
14121596 () =>
......@@ -1516,7 +1700,7 @@ export function ConnectionsSettings() {
15161700 const setDefaultAdvertisedEndpointKey = useUiStateStore(
15171701 (state) => state.setDefaultAdvertisedEndpointKey,
15181702 );
1519 const canManageLocalBackend = currentSessionRole === "owner";
1703 const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false;
15201704 const isLocalBackendNetworkAccessible = desktopBridge
15211705 ? desktopServerExposureState?.mode === "network-accessible"
15221706 : currentAuthPolicy === "remote-reachable";
......@@ -1929,30 +2113,6 @@ export function ConnectionsSettings() {
19292113 savedBackendMode,
19302114 ]);
19312115
1932 useEffect(() => {
1933 if (desktopBridge) {
1934 setCurrentSessionRole("owner");
1935 return;
1936 }
1937
1938 let cancelled = false;
1939 void fetchSessionState()
1940 .then((session) => {
1941 if (cancelled) return;
1942 setCurrentSessionRole(session.authenticated ? (session.role ?? null) : null);
1943 setCurrentAuthPolicy(session.auth.policy);
1944 })
1945 .catch(() => {
1946 if (cancelled) return;
1947 setCurrentSessionRole(null);
1948 setCurrentAuthPolicy(null);
1949 });
1950
1951 return () => {
1952 cancelled = true;
1953 };
1954 }, [desktopBridge]);
1955
19562116 useEffect(() => {
19572117 if (!canManageLocalBackend) return;
19582118
......@@ -2065,10 +2225,7 @@ export function ConnectionsSettings() {
20652225 setDesktopAdvertisedEndpoints([]);
20662226 setDesktopServerExposureError(null);
20672227 }, [canManageLocalBackend]);
2068 const visibleDesktopPairingLinks = useMemo(
2069 () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"),
2070 [desktopPairingLinks],
2071 );
2228 const visibleDesktopPairingLinks = desktopPairingLinks;
20722229 const tailscaleHttpsEndpoint = useMemo(
20732230 () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null,
20742231 [desktopAdvertisedEndpoints],
......@@ -2650,8 +2807,8 @@ export function ConnectionsSettings() {
26502807 ) : (
26512808 <SettingsSection title="Local backend access">
26522809 <SettingsRow
2653 title="Owner tools"
2654 description="Pairing links and client-session management are only available to owner sessions for this backend."
2810 title="Administrative access"
2811 description="Pairing links and client-session management require the access:write scope for this backend."
26552812 />
26562813 </SettingsSection>
26572814 )}
apps/web/src/components/settings/SettingsPanels.browser.tsx+32-21
......@@ -3,6 +3,7 @@ import "../../index.css";
33import {
44 type AuthAccessStreamEvent,
55 type AuthAccessSnapshot,
6 type AuthEnvironmentScope,
67 AuthSessionId,
78 DEFAULT_SERVER_SETTINGS,
89 EnvironmentId,
......@@ -217,7 +218,7 @@ function createBaseServerConfig(): ServerConfig {
217218 auth: {
218219 policy: "loopback-browser",
219220 bootstrapMethods: ["one-time-token"],
220 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
221 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
221222 sessionCookieName: "t3_session",
222223 },
223224 cwd: "/repo/project",
......@@ -286,7 +287,7 @@ function createEmptyProcessResourceHistoryResult(): ServerProcessResourceHistory
286287function makePairingLink(input: {
287288 readonly id: string;
288289 readonly credential: string;
289 readonly role: "owner" | "client";
290 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
290291 readonly subject: string;
291292 readonly label?: string;
292293 readonly createdAt: string;
......@@ -302,7 +303,7 @@ function makePairingLink(input: {
302303function makeClientSession(input: {
303304 readonly sessionId: string;
304305 readonly subject: string;
305 readonly role: "owner" | "client";
306 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
306307 readonly method: "browser-session-cookie";
307308 readonly client?: {
308309 readonly label?: string;
......@@ -394,26 +395,26 @@ const createDesktopBridgeStub = (overrides?: {
394395 },
395396 }),
396397 bootstrapSshBearerSession: vi.fn().mockResolvedValue({
397 authenticated: true,
398 role: "owner",
399 sessionMethod: "bearer-session-token",
400 expiresAt: "2026-05-01T12:00:00.000Z",
401 sessionToken: "ssh-bearer-token",
398 access_token: "ssh-bearer-token",
399 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
400 token_type: "Bearer",
401 expires_in: 3_600,
402 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
402403 }),
403404 fetchSshSessionState: vi.fn().mockResolvedValue({
404405 authenticated: true,
405406 auth: {
406407 policy: "remote-reachable",
407408 bootstrapMethods: ["one-time-token"],
408 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
409 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
409410 sessionCookieName: "t3_session",
410411 },
411 role: "owner",
412 sessionMethod: "bearer-session-token",
412 scopes: ["orchestration:read", "access:write"],
413 sessionMethod: "bearer-access-token",
413414 expiresAt: "2026-05-01T12:00:00.000Z",
414415 }),
415 issueSshWebSocketToken: vi.fn().mockResolvedValue({
416 token: "ssh-ws-token",
416 issueSshWebSocketTicket: vi.fn().mockResolvedValue({
417 ticket: "ssh-ws-ticket",
417418 expiresAt: "2026-05-01T12:05:00.000Z",
418419 }),
419420 onSshPasswordPrompt: vi.fn(() => () => {}),
......@@ -508,7 +509,7 @@ describe("GeneralSettingsPanel observability", () => {
508509 makeClientSession({
509510 sessionId: "session-owner",
510511 subject: "browser-owner",
511 role: "owner",
512 scopes: ["orchestration:read", "access:write"],
512513 method: "browser-session-cookie",
513514 client: {
514515 label: "Chrome on Mac",
......@@ -531,7 +532,7 @@ describe("GeneralSettingsPanel observability", () => {
531532 JSON.stringify({
532533 authenticated: true,
533534 auth: createBaseServerConfig().auth,
534 role: "owner",
535 scopes: ["orchestration:read", "access:write"],
535536 sessionMethod: "browser-session-cookie",
536537 expiresAt: "2036-05-07T00:00:00.000Z",
537538 }),
......@@ -776,7 +777,7 @@ describe("GeneralSettingsPanel observability", () => {
776777 makeClientSession({
777778 sessionId: "session-owner",
778779 subject: "desktop-bootstrap",
779 role: "owner",
780 scopes: ["orchestration:read", "access:write"],
780781 method: "browser-session-cookie",
781782 client: {
782783 label: "This Mac",
......@@ -805,7 +806,7 @@ describe("GeneralSettingsPanel observability", () => {
805806 makePairingLink({
806807 id: "pairing-link-1",
807808 credential: "pairing-token",
808 role: "client",
809 scopes: ["orchestration:read"],
809810 subject: "one-time-token",
810811 label: "Julius iPhone",
811812 createdAt: "2036-04-07T00:00:00.000Z",
......@@ -817,7 +818,7 @@ describe("GeneralSettingsPanel observability", () => {
817818 makeClientSession({
818819 sessionId: "session-client",
819820 subject: "one-time-token",
820 role: "client",
821 scopes: ["orchestration:read"],
821822 method: "browser-session-cookie",
822823 client: {
823824 label: "Julius iPhone",
......@@ -867,12 +868,22 @@ describe("GeneralSettingsPanel observability", () => {
867868 await expect.element(page.getByText("This Mac")).toBeInTheDocument();
868869 await page.getByRole("button", { name: "Create link", exact: true }).click();
869870 await expect.element(page.getByText("Create pairing link")).toBeInTheDocument();
871 await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked();
872 await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).toBeChecked();
873 await page.getByRole("button", { name: "Read only", exact: true }).click();
874 await expect.element(page.getByRole("checkbox", { name: /View environment/ })).toBeChecked();
875 await expect.element(page.getByRole("checkbox", { name: /Operate tasks/ })).not.toBeChecked();
870876 await page.getByRole("button", { name: "Create link", exact: true }).click();
871877 authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!);
872878 authAccessHarness.emitClientUpserted(clientSessions[1]!);
873879 await expect
874 .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88"))
880 .element(page.getByRole("button", { name: "Pairing link scopes: show 1 scope" }))
881 .toBeInTheDocument();
882 await expect
883 .element(page.getByText("Mobile · iOS · Safari · 192.168.1.88"))
875884 .toBeInTheDocument();
885 await page.getByRole("button", { name: "Client scopes: show 1 scope" }).click();
886 await expect.element(page.getByText("orchestration:read", { exact: true })).toBeInTheDocument();
876887 await expect
877888 .element(page.getByRole("button", { name: /^Copy pairing URL for:/ }))
878889 .toBeInTheDocument();
......@@ -893,7 +904,7 @@ describe("GeneralSettingsPanel observability", () => {
893904 makeClientSession({
894905 sessionId: "session-owner",
895906 subject: "desktop-bootstrap",
896 role: "owner",
907 scopes: ["orchestration:read", "access:write"],
897908 method: "browser-session-cookie",
898909 client: {
899910 label: "This Mac",
......@@ -909,7 +920,7 @@ describe("GeneralSettingsPanel observability", () => {
909920 makeClientSession({
910921 sessionId: "session-client",
911922 subject: "one-time-token",
912 role: "client",
923 scopes: ["orchestration:read"],
913924 method: "browser-session-cookie",
914925 client: {
915926 label: "Julius iPhone",
apps/web/src/environments/primary/auth.ts+209-132
......@@ -1,21 +1,25 @@
11import type {
2 AuthBootstrapInput,
3 AuthBootstrapResult,
2 AuthBrowserSessionResult,
43 AuthClientMetadata,
5 AuthCreatePairingCredentialInput,
4 AuthEnvironmentScope,
65 AuthPairingCredentialResult,
7 AuthRevokeClientSessionInput,
8 AuthRevokePairingLinkInput,
96 AuthSessionId,
107 AuthSessionState,
118} from "@t3tools/contracts";
9import { EnvironmentHttpCommonError } from "@t3tools/contracts";
10import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
14import { HttpClientError } from "effect/unstable/http";
1215
1316import {
1417 getPairingTokenFromUrl,
1518 stripPairingTokenFromUrl as stripPairingTokenUrl,
1619} from "../../pairingUrl";
1720
18import { resolvePrimaryEnvironmentHttpUrl } from "./target";
21import { PrimaryEnvironmentHttpClient } from "./httpClient";
22import { runPrimaryHttp } from "../../lib/runtime";
1923import * as Data from "effect/Data";
2024import * as Predicate from "effect/Predicate";
2125
......@@ -25,11 +29,12 @@ export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{
2529}> {}
2630const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError =>
2731 Predicate.isTagged(u, "BootstrapHttpError");
32const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
2833
2934export interface ServerPairingLinkRecord {
3035 readonly id: string;
3136 readonly credential: string;
32 readonly role: "owner" | "client";
37 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
3338 readonly subject: string;
3439 readonly label?: string;
3540 readonly createdAt: string;
......@@ -39,8 +44,8 @@ export interface ServerPairingLinkRecord {
3944export interface ServerClientSessionRecord {
4045 readonly sessionId: AuthSessionId;
4146 readonly subject: string;
42 readonly role: "owner" | "client";
43 readonly method: "browser-session-cookie" | "bearer-session-token";
47 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
48 readonly method: "browser-session-cookie" | "bearer-access-token";
4449 readonly client: AuthClientMetadata;
4550 readonly issuedAt: string;
4651 readonly expiresAt: string;
......@@ -93,79 +98,97 @@ function getDesktopBootstrapCredential(): string | null {
9398
9499export async function fetchSessionState(): Promise<AuthSessionState> {
95100 return retryTransientBootstrap(async () => {
96 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/session"), {
97 credentials: "include",
98 });
99 if (!response.ok) {
101 try {
102 return await runPrimaryHttp(
103 PrimaryEnvironmentHttpClient.pipe(
104 Effect.flatMap((client) => client.auth.session({ headers: {} })),
105 ),
106 );
107 } catch (error) {
108 const status = readHttpApiStatus(error);
100109 throw new BootstrapHttpError({
101 message: `Failed to load server auth session state (${response.status}).`,
102 status: response.status,
110 message: `Failed to load server auth session state (${status ?? "unknown"}).`,
111 status: status ?? 500,
103112 });
104113 }
105 return (await response.json()) as AuthSessionState;
106114 });
107115}
108116
109async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
110 const text = await response.text();
111 return text || fallbackMessage;
117function readHttpApiStatus(error: unknown): number | null {
118 if (isEnvironmentHttpCommonError(error)) {
119 return readEnvironmentHttpErrorStatus(error);
120 }
121 return HttpClientError.isHttpClientError(error) && error.response !== undefined
122 ? error.response.status
123 : null;
112124}
113125
114const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([
115 "Invalid bootstrap credential.",
116 "Unknown bootstrap credential.",
117]);
118
119function parseBootstrapErrorMessage(message: string): string {
120 const trimmed = message.trim();
121 if (trimmed.length === 0) {
122 return "";
126function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType): number {
127 switch (error._tag) {
128 case "EnvironmentRequestInvalidError":
129 return 400;
130 case "EnvironmentAuthInvalidError":
131 return 401;
132 case "EnvironmentScopeRequiredError":
133 case "EnvironmentOperationForbiddenError":
134 return 403;
135 case "EnvironmentInternalError":
136 return 500;
123137 }
138}
124139
125 try {
126 const parsed = JSON.parse(trimmed) as {
127 error?: unknown;
128 };
129 if (typeof parsed.error === "string" && parsed.error.trim().length > 0) {
130 return parsed.error.trim();
131 }
132 } catch {
133 // Not JSON; fall back to plain text.
140function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): string {
141 if (!isEnvironmentHttpCommonError(error)) {
142 return fallbackMessage;
143 }
144 switch (error._tag) {
145 case "EnvironmentAuthInvalidError":
146 return error.reason === "missing_credential"
147 ? "Authentication required."
148 : "Invalid bootstrap credential.";
149 case "EnvironmentRequestInvalidError":
150 return error.reason === "invalid_scope"
151 ? "Requested token scope is invalid."
152 : "Requested scope exceeds the bootstrap credential grant.";
153 case "EnvironmentScopeRequiredError":
154 return `The authenticated token is missing required scope: ${error.requiredScope}.`;
155 case "EnvironmentOperationForbiddenError":
156 return "This operation is not allowed for the current session.";
157 case "EnvironmentInternalError":
158 return fallbackMessage;
134159 }
135
136 return trimmed;
137160}
138161
162const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([
163 "Invalid bootstrap credential.",
164 "Unknown bootstrap credential.",
165]);
166
139167function toFriendlyBootstrapErrorMessage(status: number, message: string): string {
140 const parsedMessage = parseBootstrapErrorMessage(message);
141 if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(parsedMessage)) {
168 const trimmedMessage = message.trim();
169 if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) {
142170 return "Invalid pairing token. Check the token and try again.";
143171 }
144172
145 return parsedMessage;
173 return trimmedMessage;
146174}
147175
148async function exchangeBootstrapCredential(credential: string): Promise<AuthBootstrapResult> {
176async function exchangeBootstrapCredential(credential: string): Promise<AuthBrowserSessionResult> {
149177 return retryTransientBootstrap(async () => {
150 const payload: AuthBootstrapInput = { credential };
151 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/bootstrap"), {
152 body: JSON.stringify(payload),
153 credentials: "include",
154 headers: {
155 "content-type": "application/json",
156 },
157 method: "POST",
158 });
159
160 if (!response.ok) {
161 const message = toFriendlyBootstrapErrorMessage(response.status, await response.text());
178 try {
179 return await runPrimaryHttp(
180 PrimaryEnvironmentHttpClient.pipe(
181 Effect.flatMap((client) => client.auth.browserSession({ payload: { credential } })),
182 ),
183 );
184 } catch (error) {
185 const status = readHttpApiStatus(error) ?? 500;
186 const message = toFriendlyBootstrapErrorMessage(status, readHttpApiErrorMessage(error, ""));
162187 throw new BootstrapHttpError({
163 message: message || `Failed to bootstrap auth session (${response.status}).`,
164 status: response.status,
188 message: message || `Failed to bootstrap auth session (${status}).`,
189 status,
165190 });
166191 }
167
168 return (await response.json()) as AuthBootstrapResult;
169192 });
170193}
171194
......@@ -266,57 +289,93 @@ export async function submitServerAuthCredential(credential: string): Promise<vo
266289 stripPairingTokenFromUrl();
267290}
268291
269export async function createServerPairingCredential(
270 label?: string,
271): Promise<AuthPairingCredentialResult> {
272 const trimmedLabel = label?.trim();
273 const payload: AuthCreatePairingCredentialInput = trimmedLabel ? { label: trimmedLabel } : {};
274 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-token"), {
275 body: JSON.stringify(payload),
276 credentials: "include",
277 headers: {
278 "content-type": "application/json",
279 },
280 method: "POST",
281 });
282
283 if (!response.ok) {
292export async function createServerPairingCredential(input?: {
293 readonly label?: string;
294 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
295}): Promise<AuthPairingCredentialResult> {
296 const trimmedLabel = input?.label?.trim();
297 try {
298 return await runPrimaryHttp(
299 PrimaryEnvironmentHttpClient.pipe(
300 Effect.flatMap((client) =>
301 client.auth.pairingCredential({
302 headers: {},
303 payload: {
304 ...(trimmedLabel ? { label: trimmedLabel } : {}),
305 ...(input?.scopes ? { scopes: input.scopes } : {}),
306 },
307 }),
308 ),
309 ),
310 );
311 } catch (error) {
284312 throw new Error(
285 await readErrorMessage(response, `Failed to create pairing credential (${response.status}).`),
313 readHttpApiErrorMessage(
314 error,
315 `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`,
316 ),
317 { cause: error },
286318 );
287319 }
288
289 return (await response.json()) as AuthPairingCredentialResult;
290320}
291321
292322export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPairingLinkRecord>> {
293 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links"), {
294 credentials: "include",
295 });
296
297 if (!response.ok) {
323 try {
324 const pairingLinks = await runPrimaryHttp(
325 PrimaryEnvironmentHttpClient.pipe(
326 Effect.flatMap((client) => client.auth.pairingLinks({ headers: {} })),
327 ),
328 );
329 return pairingLinks.map((pairingLink) => {
330 const timestamps = {
331 createdAt: DateTime.formatIso(pairingLink.createdAt),
332 expiresAt: DateTime.formatIso(pairingLink.expiresAt),
333 };
334 if (pairingLink.label === undefined) {
335 return {
336 id: pairingLink.id,
337 credential: pairingLink.credential,
338 scopes: pairingLink.scopes,
339 subject: pairingLink.subject,
340 createdAt: timestamps.createdAt,
341 expiresAt: timestamps.expiresAt,
342 };
343 }
344 return {
345 id: pairingLink.id,
346 credential: pairingLink.credential,
347 scopes: pairingLink.scopes,
348 subject: pairingLink.subject,
349 label: pairingLink.label,
350 createdAt: timestamps.createdAt,
351 expiresAt: timestamps.expiresAt,
352 };
353 });
354 } catch (error) {
298355 throw new Error(
299 await readErrorMessage(response, `Failed to load pairing links (${response.status}).`),
356 readHttpApiErrorMessage(
357 error,
358 `Failed to load pairing links (${readHttpApiStatus(error) ?? "unknown"}).`,
359 ),
360 { cause: error },
300361 );
301362 }
302
303 return (await response.json()) as ReadonlyArray<ServerPairingLinkRecord>;
304363}
305364
306365export async function revokeServerPairingLink(id: string): Promise<void> {
307 const payload: AuthRevokePairingLinkInput = { id };
308 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links/revoke"), {
309 body: JSON.stringify(payload),
310 credentials: "include",
311 headers: {
312 "content-type": "application/json",
313 },
314 method: "POST",
315 });
316
317 if (!response.ok) {
366 try {
367 await runPrimaryHttp(
368 PrimaryEnvironmentHttpClient.pipe(
369 Effect.flatMap((client) => client.auth.revokePairingLink({ headers: {}, payload: { id } })),
370 ),
371 );
372 } catch (error) {
318373 throw new Error(
319 await readErrorMessage(response, `Failed to revoke pairing link (${response.status}).`),
374 readHttpApiErrorMessage(
375 error,
376 `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`,
377 ),
378 { cause: error },
320379 );
321380 }
322381}
......@@ -324,57 +383,75 @@ export async function revokeServerPairingLink(id: string): Promise<void> {
324383export async function listServerClientSessions(): Promise<
325384 ReadonlyArray<ServerClientSessionRecord>
326385> {
327 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients"), {
328 credentials: "include",
329 });
330
331 if (!response.ok) {
386 try {
387 const clientSessions = await runPrimaryHttp(
388 PrimaryEnvironmentHttpClient.pipe(
389 Effect.flatMap((client) => client.auth.clients({ headers: {} })),
390 ),
391 );
392 return clientSessions.map((clientSession) => ({
393 sessionId: clientSession.sessionId,
394 subject: clientSession.subject,
395 scopes: clientSession.scopes,
396 method: clientSession.method,
397 client: clientSession.client,
398 issuedAt: DateTime.formatIso(clientSession.issuedAt),
399 expiresAt: DateTime.formatIso(clientSession.expiresAt),
400 lastConnectedAt:
401 clientSession.lastConnectedAt === null
402 ? null
403 : DateTime.formatIso(clientSession.lastConnectedAt),
404 connected: clientSession.connected,
405 current: clientSession.current,
406 }));
407 } catch (error) {
332408 throw new Error(
333 await readErrorMessage(response, `Failed to load paired clients (${response.status}).`),
409 readHttpApiErrorMessage(
410 error,
411 `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`,
412 ),
413 { cause: error },
334414 );
335415 }
336
337 return (await response.json()) as ReadonlyArray<ServerClientSessionRecord>;
338416}
339417
340418export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> {
341 const payload: AuthRevokeClientSessionInput = { sessionId };
342 const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke"), {
343 body: JSON.stringify(payload),
344 credentials: "include",
345 headers: {
346 "content-type": "application/json",
347 },
348 method: "POST",
349 });
350
351 if (!response.ok) {
419 try {
420 await runPrimaryHttp(
421 PrimaryEnvironmentHttpClient.pipe(
422 Effect.flatMap((client) =>
423 client.auth.revokeClient({ headers: {}, payload: { sessionId } }),
424 ),
425 ),
426 );
427 } catch (error) {
352428 throw new Error(
353 await readErrorMessage(response, `Failed to revoke client session (${response.status}).`),
429 readHttpApiErrorMessage(
430 error,
431 `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`,
432 ),
433 { cause: error },
354434 );
355435 }
356436}
357437
358438export async function revokeOtherServerClientSessions(): Promise<number> {
359 const response = await fetch(
360 resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke-others"),
361 {
362 credentials: "include",
363 method: "POST",
364 },
365 );
366
367 if (!response.ok) {
439 try {
440 const result = await runPrimaryHttp(
441 PrimaryEnvironmentHttpClient.pipe(
442 Effect.flatMap((client) => client.auth.revokeOtherClients({ headers: {} })),
443 ),
444 );
445 return result.revokedCount;
446 } catch (error) {
368447 throw new Error(
369 await readErrorMessage(
370 response,
371 `Failed to revoke other client sessions (${response.status}).`,
448 readHttpApiErrorMessage(
449 error,
450 `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`,
372451 ),
452 { cause: error },
373453 );
374454 }
375
376 const result = (await response.json()) as { revokedCount?: number };
377 return result.revokedCount ?? 0;
378455}
379456
380457export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {
apps/web/src/environments/primary/bootstrap.test.ts+41-33
......@@ -1,25 +1,18 @@
11import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { EnvironmentId } from "@t3tools/contracts";
2import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
34
45import {
56 getPrimaryKnownEnvironment,
7 resolvePrimaryEnvironmentHttpUrl,
68 resolveInitialPrimaryEnvironmentDescriptor,
79 resetPrimaryEnvironmentDescriptorForTests,
810 writePrimaryEnvironmentDescriptor,
911} from ".";
10
11function jsonResponse(body: unknown, init?: ResponseInit) {
12 return new Response(JSON.stringify(body), {
13 headers: {
14 "content-type": "application/json",
15 },
16 status: 200,
17 ...init,
18 });
19}
12import { installEnvironmentHttpTest } from "../../../test/environmentHttpTest";
2013
2114const BASE_ENVIRONMENT = {
22 environmentId: "environment-local",
15 environmentId: EnvironmentId.make("environment-local"),
2316 label: "Local environment",
2417 platform: {
2518 os: "darwin",
......@@ -29,7 +22,17 @@ const BASE_ENVIRONMENT = {
2922 capabilities: {
3023 repositoryIdentity: true,
3124 },
32};
25} satisfies ExecutionEnvironmentDescriptor;
26
27let disposeHttpTest: (() => Promise<void>) | undefined;
28
29async function installDescriptorApi() {
30 const testApi = await installEnvironmentHttpTest({
31 descriptor: () => Effect.succeed(BASE_ENVIRONMENT),
32 });
33 disposeHttpTest = testApi.dispose;
34 return testApi;
35}
3336
3437function installTestBrowser(url: string) {
3538 vi.stubGlobal("window", {
......@@ -47,7 +50,9 @@ describe("environmentBootstrap", () => {
4750 installTestBrowser("http://localhost/");
4851 });
4952
50 afterEach(() => {
53 afterEach(async () => {
54 await disposeHttpTest?.();
55 disposeHttpTest = undefined;
5156 resetPrimaryEnvironmentDescriptorForTests();
5257 vi.unstubAllEnvs();
5358 vi.useRealTimers();
......@@ -88,35 +93,35 @@ describe("environmentBootstrap", () => {
8893 });
8994
9095 it("reuses an in-flight descriptor bootstrap request", async () => {
91 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
92 vi.stubGlobal("fetch", fetchMock);
96 const testApi = await installDescriptorApi();
9397
9498 await Promise.all([
9599 resolveInitialPrimaryEnvironmentDescriptor(),
96100 resolveInitialPrimaryEnvironmentDescriptor(),
97101 ]);
98102
99 expect(fetchMock).toHaveBeenCalledTimes(1);
100 expect(fetchMock).toHaveBeenCalledWith("http://localhost/.well-known/t3/environment");
103 expect(testApi.calls.descriptor).toBe(1);
101104 });
102105
103106 it("uses https descriptor urls when the primary environment uses wss", async () => {
104 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
105 vi.stubGlobal("fetch", fetchMock);
106107 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
107108 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
109 await installDescriptorApi();
108110
109111 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
110 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
112 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
113 "https://remote.example.com/.well-known/t3/environment",
114 );
111115 });
112116
113117 it("derives the websocket url when only VITE_HTTP_URL is configured", async () => {
114 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
115 vi.stubGlobal("fetch", fetchMock);
116118 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
119 await installDescriptorApi();
117120
118121 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
119 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
122 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
123 "https://remote.example.com/.well-known/t3/environment",
124 );
120125 expect(getPrimaryKnownEnvironment()?.target).toEqual({
121126 httpBaseUrl: "https://remote.example.com/",
122127 wsBaseUrl: "wss://remote.example.com/",
......@@ -124,12 +129,13 @@ describe("environmentBootstrap", () => {
124129 });
125130
126131 it("derives the http url when only VITE_WS_URL is configured", async () => {
127 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
128 vi.stubGlobal("fetch", fetchMock);
129132 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
133 await installDescriptorApi();
130134
131135 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
132 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
136 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
137 "https://remote.example.com/.well-known/t3/environment",
138 );
133139 expect(getPrimaryKnownEnvironment()?.target).toEqual({
134140 httpBaseUrl: "https://remote.example.com/",
135141 wsBaseUrl: "wss://remote.example.com/",
......@@ -137,17 +143,16 @@ describe("environmentBootstrap", () => {
137143 });
138144
139145 it("uses the current origin as the descriptor base for local dev environments", async () => {
140 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
141 vi.stubGlobal("fetch", fetchMock);
142146 installTestBrowser("http://localhost:5735/");
147 await installDescriptorApi();
143148
144149 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
145 expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/.well-known/t3/environment");
150 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
151 "http://localhost:5735/.well-known/t3/environment",
152 );
146153 });
147154
148155 it("uses the vite proxy for desktop-managed loopback descriptor requests during local dev", async () => {
149 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
150 vi.stubGlobal("fetch", fetchMock);
151156 vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733");
152157 vi.stubGlobal("window", {
153158 location: new URL("http://127.0.0.1:5733/"),
......@@ -163,8 +168,11 @@ describe("environmentBootstrap", () => {
163168 }),
164169 },
165170 });
171 await installDescriptorApi();
166172
167173 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
168 expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:5733/.well-known/t3/environment");
174 expect(resolvePrimaryEnvironmentHttpUrl("/.well-known/t3/environment")).toBe(
175 "http://127.0.0.1:5733/.well-known/t3/environment",
176 );
169177 });
170178});
apps/web/src/environments/primary/context.ts+17-10
......@@ -4,13 +4,15 @@ import {
44 type KnownEnvironment,
55} from "@t3tools/client-runtime";
66import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import { HttpClientError } from "effect/unstable/http";
79import { create } from "zustand";
810
911import { BootstrapHttpError, retryTransientBootstrap } from "./auth";
12import { PrimaryEnvironmentHttpClient } from "./httpClient";
1013
11import { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl } from "./target";
12
13const SERVER_ENVIRONMENT_DESCRIPTOR_PATH = "/.well-known/t3/environment";
14import { runPrimaryHttp } from "../../lib/runtime";
15import { readPrimaryEnvironmentTarget } from "./target";
1416
1517interface PrimaryEnvironmentBootstrapState {
1618 readonly descriptor: ExecutionEnvironmentDescriptor | null;
......@@ -48,17 +50,22 @@ function createPrimaryKnownEnvironment(input: {
4850
4951async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> {
5052 return retryTransientBootstrap(async () => {
51 const response = await fetch(
52 resolvePrimaryEnvironmentHttpUrl(SERVER_ENVIRONMENT_DESCRIPTOR_PATH),
53 );
54 if (!response.ok) {
53 let descriptor: ExecutionEnvironmentDescriptor;
54 try {
55 descriptor = await runPrimaryHttp(
56 PrimaryEnvironmentHttpClient.pipe(Effect.flatMap((client) => client.metadata.descriptor())),
57 );
58 } catch (error) {
59 const status =
60 HttpClientError.isHttpClientError(error) && error.response !== undefined
61 ? error.response.status
62 : 500;
5563 throw new BootstrapHttpError({
56 message: `Failed to load server environment descriptor (${response.status}).`,
57 status: response.status,
64 message: `Failed to load server environment descriptor (${status}).`,
65 status,
5866 });
5967 }
6068
61 const descriptor = (await response.json()) as ExecutionEnvironmentDescriptor;
6269 writePrimaryEnvironmentDescriptor(descriptor);
6370 return descriptor;
6471 });
apps/web/src/environments/primary/httpClient.ts created+20
......@@ -0,0 +1,20 @@
1import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { resolvePrimaryEnvironmentHttpUrl } from "./target";
7
8export type PrimaryEnvironmentHttpClientShape = Effect.Success<
9 ReturnType<typeof makeEnvironmentHttpApiClient>
10>;
11
12export class PrimaryEnvironmentHttpClient extends Context.Service<
13 PrimaryEnvironmentHttpClient,
14 PrimaryEnvironmentHttpClientShape
15>()("@t3tools/web/environments/primary/httpClient/PrimaryEnvironmentHttpClient") {}
16
17export const primaryEnvironmentHttpClientLive = Layer.effect(
18 PrimaryEnvironmentHttpClient,
19 Effect.suspend(() => makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"))),
20);
apps/web/src/environments/primary/index.ts+2
......@@ -32,4 +32,6 @@ export {
3232 __resetServerAuthBootstrapForTests,
3333} from "./auth";
3434
35export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState";
36
3537export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";
apps/web/src/environments/primary/sessionState.ts created+37
......@@ -0,0 +1,37 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6import { useCallback } from "react";
7
8import { appAtomRegistry } from "../../rpc/atomRegistry";
9import { fetchSessionState } from "./auth";
10
11const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe(
12 Atom.swr({ staleTime: 5_000, revalidateOnMount: true }),
13 Atom.setIdleTTL(5 * 60_000),
14 Atom.withLabel("primary-environment:session"),
15);
16
17export function refreshPrimarySessionState(): void {
18 appAtomRegistry.refresh(primarySessionStateAtom);
19}
20
21export function usePrimarySessionState() {
22 const result = useAtomValue(primarySessionStateAtom);
23 const refresh = useCallback(() => {
24 refreshPrimarySessionState();
25 }, []);
26 let error: string | null = null;
27 if (result._tag === "Failure") {
28 const cause = Cause.squash(result.cause);
29 error = cause instanceof Error ? cause.message : "Could not read environment session.";
30 }
31 return {
32 data: Option.getOrNull(AsyncResult.value(result)),
33 error,
34 isPending: result.waiting,
35 refresh,
36 };
37}
apps/web/src/environments/runtime/catalog.ts+3-3
......@@ -1,6 +1,6 @@
11import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime";
22import type {
3 AuthSessionRole,
3 AuthEnvironmentScope,
44 EnvironmentId,
55 ExecutionEnvironmentDescriptor,
66 PersistedSavedEnvironmentRecord,
......@@ -272,7 +272,7 @@ export interface SavedEnvironmentRuntimeState {
272272 readonly authState: SavedEnvironmentAuthState;
273273 readonly lastError: string | null;
274274 readonly lastErrorAt: string | null;
275 readonly role: AuthSessionRole | null;
275 readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null;
276276 readonly descriptor: ExecutionEnvironmentDescriptor | null;
277277 readonly serverConfig: ServerConfig | null;
278278 readonly connectedAt: string | null;
......@@ -295,7 +295,7 @@ const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Ob
295295 authState: "unknown",
296296 lastError: null,
297297 lastErrorAt: null,
298 role: null,
298 scopes: null,
299299 descriptor: null,
300300 serverConfig: null,
301301 connectedAt: null,
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+30-31
......@@ -1,13 +1,15 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
23import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
5const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError);
6
47let mockSavedRecords: Array<Record<string, unknown>> = [];
58
69const mockResolveRemotePairingTarget = vi.fn();
710const mockFetchRemoteEnvironmentDescriptor = vi.fn();
811const mockBootstrapRemoteBearerSession = vi.fn();
912const mockFetchRemoteSessionState = vi.fn();
10const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false);
1113const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
1214const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
1315const mockBootstrapSshBearerSession = vi.fn();
......@@ -128,7 +130,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
128130 })),
129131 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
130132 fetchRemoteSessionState: mockFetchRemoteSessionState,
131 isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError,
132133 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
133134 };
134135});
......@@ -153,7 +154,7 @@ describe("addSavedEnvironment", () => {
153154 fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor,
154155 bootstrapSshBearerSession: mockBootstrapSshBearerSession,
155156 fetchSshSessionState: mockFetchSshSessionState,
156 issueSshWebSocketToken: vi.fn(),
157 issueSshWebSocketTicket: vi.fn(),
157158 },
158159 });
159160 mockResolveRemotePairingTarget.mockImplementation(
......@@ -176,24 +177,23 @@ describe("addSavedEnvironment", () => {
176177 label: "Remote environment",
177178 });
178179 mockBootstrapRemoteBearerSession.mockResolvedValue({
179 sessionToken: "bearer-token",
180 role: "owner",
180 access_token: "bearer-token",
181 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
181182 });
182183 mockFetchRemoteSessionState.mockResolvedValue({
183184 authenticated: true,
184 role: "owner",
185 scopes: ["orchestration:read", "access:write"],
185186 });
186 mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false);
187187 mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue(
188 "wss://remote.example.com/?wsToken=remote-token",
188 "wss://remote.example.com/?wsTicket=remote-token",
189189 );
190190 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
191191 environmentId: EnvironmentId.make("environment-1"),
192192 label: "Remote environment",
193193 });
194194 mockBootstrapSshBearerSession.mockResolvedValue({
195 sessionToken: "ssh-bearer-token",
196 role: "owner",
195 access_token: "ssh-bearer-token",
196 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
197197 });
198198 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
199199 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
......@@ -202,7 +202,7 @@ describe("addSavedEnvironment", () => {
202202 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);
203203 mockFetchSshSessionState.mockResolvedValue({
204204 authenticated: true,
205 role: "owner",
205 scopes: ["orchestration:read", "access:write"],
206206 });
207207 mockCreateEnvironmentConnection.mockImplementation(
208208 (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({
......@@ -381,18 +381,18 @@ describe("addSavedEnvironment", () => {
381381 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
382382 mockBootstrapSshBearerSession
383383 .mockResolvedValueOnce({
384 sessionToken: "ssh-bearer-token",
385 role: "owner",
384 access_token: "ssh-bearer-token",
385 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
386386 })
387387 .mockResolvedValueOnce({
388 sessionToken: "ssh-bearer-token-2",
389 role: "owner",
388 access_token: "ssh-bearer-token-2",
389 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
390390 });
391391 mockFetchSshSessionState
392392 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))
393393 .mockResolvedValueOnce({
394394 authenticated: true,
395 role: "owner",
395 scopes: ["orchestration:read", "access:write"],
396396 });
397397
398398 const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } =
......@@ -418,14 +418,13 @@ describe("addSavedEnvironment", () => {
418418
419419 it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => {
420420 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
421 const authError = {
422 status: 401,
423 message: "Unauthorized",
424 };
421 const authError = decodeEnvironmentAuthInvalidError({
422 _tag: "EnvironmentAuthInvalidError",
423 code: "auth_invalid",
424 reason: "invalid_credential",
425 traceId: "trace-auth-test",
426 });
425427 mockFetchRemoteSessionState.mockRejectedValueOnce(authError);
426 mockIsRemoteEnvironmentAuthHttpError.mockImplementation(
427 (error: unknown) => error === authError,
428 );
429428
430429 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
431430
......@@ -450,18 +449,18 @@ describe("addSavedEnvironment", () => {
450449 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
451450 mockBootstrapSshBearerSession
452451 .mockResolvedValueOnce({
453 sessionToken: "ssh-bearer-token",
454 role: "owner",
452 access_token: "ssh-bearer-token",
453 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
455454 })
456455 .mockResolvedValueOnce({
457 sessionToken: "ssh-bearer-token-2",
458 role: "owner",
456 access_token: "ssh-bearer-token-2",
457 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
459458 });
460459 mockFetchSshSessionState
461460 .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized"))
462461 .mockResolvedValueOnce({
463462 authenticated: true,
464 role: "owner",
463 scopes: ["orchestration:read", "access:write"],
465464 });
466465
467466 const createdConnections: Array<{
......@@ -745,7 +744,7 @@ describe("addSavedEnvironment", () => {
745744 );
746745 let resolveSessionState!: (value: {
747746 readonly authenticated: true;
748 readonly role: "owner";
747 readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">;
749748 }) => void;
750749 mockFetchRemoteSessionState.mockReturnValue(
751750 new Promise((resolve) => {
......@@ -768,7 +767,7 @@ describe("addSavedEnvironment", () => {
768767 await disconnectSavedEnvironment(EnvironmentId.make("environment-1"));
769768 resolveSessionState({
770769 authenticated: true,
771 role: "owner",
770 scopes: ["orchestration:read", "access:write"],
772771 });
773772 await expect(reconnectPromise).resolves.toBeUndefined();
774773
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+1-1
......@@ -222,7 +222,7 @@ describe("saved environment startup", () => {
222222
223223 mockFetchRemoteSessionState.mockResolvedValue({
224224 authenticated: true,
225 role: "owner",
225 scopes: ["orchestration:read", "access:write"],
226226 });
227227 mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) =>
228228 environmentId === savedRecord.environmentId ? savedRecord : null,
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+1-2
......@@ -153,7 +153,6 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
153153 ...actual,
154154 createWsRpcClient: vi.fn(() => stubWsClient),
155155 fetchRemoteSessionState: mockFetchRemoteSessionState,
156 isRemoteEnvironmentAuthHttpError: vi.fn(() => false),
157156 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
158157 };
159158});
......@@ -304,7 +303,7 @@ describe("retainThreadDetailSubscription", () => {
304303 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
305304 mockFetchRemoteSessionState.mockResolvedValue({
306305 authenticated: true,
307 role: "client",
306 scopes: ["orchestration:read"],
308307 });
309308 mockConnectionReconnects.length = 0;
310309 });
apps/web/src/environments/runtime/service.ts+31-22
......@@ -1,5 +1,6 @@
11import {
2 type AuthSessionRole,
2 AuthEnvironmentScope,
3 AuthStandardClientScopes,
34 type DesktopSshEnvironmentBootstrap,
45 type DesktopSshEnvironmentTarget,
56 type EnvironmentId,
......@@ -7,6 +8,7 @@ import {
78 type OrchestrationShellSnapshot,
89 type OrchestrationShellStreamEvent,
910 type ServerConfig,
11 EnvironmentAuthInvalidError,
1012 ThreadId,
1113} from "@t3tools/contracts";
1214import {
......@@ -15,12 +17,12 @@ import {
1517 bootstrapRemoteBearerSession,
1618 fetchRemoteEnvironmentDescriptor,
1719 fetchRemoteSessionState,
18 isRemoteEnvironmentAuthHttpError,
1920 resolveRemoteWebSocketConnectionUrl,
2021} from "@t3tools/client-runtime";
2122
2223import { type QueryClient } from "@tanstack/react-query";
2324import { Throttler } from "@tanstack/react-pacer";
25import * as Schema from "effect/Schema";
2426import {
2527 createKnownEnvironment,
2628 getKnownEnvironmentWsBaseUrl,
......@@ -76,6 +78,8 @@ import {
7678 deriveLogicalProjectKeyFromSettings,
7779 derivePhysicalProjectKey,
7880} from "../../logicalProject";
81
82const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope));
7983import { getClientSettings } from "~/hooks/useSettings";
8084import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState";
8185import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState";
......@@ -99,6 +103,7 @@ type ThreadDetailSubscriptionEntry = {
99103};
100104
101105const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();
106const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
102107
103108function isSavedEnvironmentConnectionCancelledError(
104109 error: unknown,
......@@ -738,6 +743,10 @@ async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential:
738743 return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential);
739744}
740745
746function readIssuedBearerScopes(scope: string): ReadonlyArray<AuthEnvironmentScope> {
747 return decodeIssuedBearerScopes(scope.split(" "));
748}
749
741750async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) {
742751 return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken);
743752}
......@@ -747,9 +756,9 @@ async function resolveDesktopSshWebSocketConnectionUrl(
747756 httpBaseUrl: string,
748757 bearerToken: string,
749758) {
750 const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken);
759 const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken);
751760 const url = new URL(wsBaseUrl, window.location.origin);
752 url.searchParams.set("wsToken", issued.token);
761 url.searchParams.set("wsTicket", issued.ticket);
753762 return url.toString();
754763}
755764
......@@ -799,7 +808,7 @@ async function prepareSavedEnvironmentRecordForConnection(
799808async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{
800809 readonly record: SavedEnvironmentRecord;
801810 readonly bearerToken: string;
802 readonly role: AuthSessionRole | null;
811 readonly scopes: ReadonlyArray<AuthEnvironmentScope> | null;
803812}> {
804813 const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]);
805814 const prepared = await prepareSavedEnvironmentRecordForConnection(record, {
......@@ -827,7 +836,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro
827836 });
828837 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(
829838 prepared.record.environmentId,
830 bearerSession.sessionToken,
839 bearerSession.access_token,
831840 );
832841 if (!didPersistBearerToken) {
833842 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
......@@ -836,8 +845,8 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro
836845
837846 return {
838847 record: prepared.record,
839 bearerToken: bearerSession.sessionToken,
840 role: bearerSession.role ?? null,
848 bearerToken: bearerSession.access_token,
849 scopes: readIssuedBearerScopes(bearerSession.scope),
841850 };
842851}
843852
......@@ -1214,7 +1223,7 @@ async function refreshSavedEnvironmentMetadata(
12141223 environmentId: EnvironmentId,
12151224 bearerToken: string,
12161225 client: WsRpcClient,
1217 roleHint?: AuthSessionRole | null,
1226 scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null,
12181227 configHint?: ServerConfig | null,
12191228): Promise<void> {
12201229 const record = getSavedEnvironmentRecord(environmentId);
......@@ -1238,7 +1247,7 @@ async function refreshSavedEnvironmentMetadata(
12381247 authState: sessionState.authenticated ? "authenticated" : "requires-auth",
12391248 descriptor: serverConfig.environment,
12401249 serverConfig,
1241 role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null,
1250 scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null,
12421251 });
12431252 useSavedEnvironmentRegistryStore
12441253 .getState()
......@@ -1311,7 +1320,7 @@ async function ensureSavedEnvironmentConnection(
13111320 options?: {
13121321 readonly client?: WsRpcClient;
13131322 readonly bearerToken?: string;
1314 readonly role?: AuthSessionRole | null;
1323 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;
13151324 readonly serverConfig?: ServerConfig | null;
13161325 },
13171326): Promise<EnvironmentConnection> {
......@@ -1330,7 +1339,7 @@ async function ensureSavedEnvironmentConnection(
13301339 isCurrent: attempt.isCurrent,
13311340 promise: Promise.resolve().then(async () => {
13321341 let activeRecord = record;
1333 let roleHint = options?.role ?? null;
1342 let scopeHint = options?.scopes ?? null;
13341343 let bearerToken =
13351344 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
13361345 if (!bearerToken) {
......@@ -1338,11 +1347,11 @@ async function ensureSavedEnvironmentConnection(
13381347 const issued = await issueDesktopSshBearerSession(record);
13391348 activeRecord = issued.record;
13401349 bearerToken = issued.bearerToken;
1341 roleHint = issued.role;
1350 scopeHint = [...AuthStandardClientScopes];
13421351 } else {
13431352 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
13441353 authState: "requires-auth",
1345 role: null,
1354 scopes: null,
13461355 connectionState: "disconnected",
13471356 lastError: "Saved environment is missing its saved credential. Pair it again.",
13481357 lastErrorAt: isoNow(),
......@@ -1409,13 +1418,13 @@ async function ensureSavedEnvironmentConnection(
14091418 activeRecord.environmentId,
14101419 activeBearerToken,
14111420 client,
1412 roleHint,
1421 scopeHint,
14131422 initialServerConfig,
14141423 );
14151424 } catch (error) {
14161425 const isAuthError = activeRecord.desktopSsh
14171426 ? isSshHttpAuthError(error, 401)
1418 : isRemoteEnvironmentAuthHttpError(error) && error.status === 401;
1427 : isEnvironmentAuthInvalidError(error);
14191428 if (!isAuthError) {
14201429 throw error;
14211430 }
......@@ -1429,12 +1438,12 @@ async function ensureSavedEnvironmentConnection(
14291438 const issued = await issueDesktopSshBearerSession(activeRecord);
14301439 activeRecord = issued.record;
14311440 bearerToken = issued.bearerToken;
1432 roleHint = issued.role;
1441 scopeHint = [...AuthStandardClientScopes];
14331442 await connection.dispose().catch(() => undefined);
14341443 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);
14351444 return await ensureSavedEnvironmentConnection(activeRecord, {
14361445 bearerToken,
1437 role: roleHint,
1446 scopes: scopeHint,
14381447 serverConfig: options?.serverConfig ?? null,
14391448 });
14401449 }
......@@ -1638,7 +1647,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P
16381647 await removeConnection(environmentId).catch(() => false);
16391648 await ensureSavedEnvironmentConnection(issued.record, {
16401649 bearerToken: issued.bearerToken,
1641 role: issued.role,
1650 scopes: [...AuthStandardClientScopes],
16421651 });
16431652 return;
16441653 } catch (recoveryError) {
......@@ -1714,7 +1723,7 @@ export async function addSavedEnvironment(input: {
17141723 await persistSavedEnvironmentRecord(record);
17151724 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(
17161725 environmentId,
1717 bearerSession.sessionToken,
1726 bearerSession.access_token,
17181727 );
17191728 if (!didPersistBearerToken) {
17201729 await persistSavedEnvironmentRegistryRollback(registrySnapshot);
......@@ -1726,8 +1735,8 @@ export async function addSavedEnvironment(input: {
17261735 }
17271736 await removeConnection(environmentId).catch(() => false);
17281737 await ensureSavedEnvironmentConnection(record, {
1729 bearerToken: bearerSession.sessionToken,
1730 role: bearerSession.role,
1738 bearerToken: bearerSession.access_token,
1739 scopes: readIssuedBearerScopes(bearerSession.scope),
17311740 });
17321741 return record;
17331742}
apps/web/src/lib/runtime.ts+34
......@@ -1,5 +1,39 @@
11import * as ManagedRuntime from "effect/ManagedRuntime";
2import type * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { FetchHttpClient } from "effect/unstable/http";
25
36import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7import {
8 PrimaryEnvironmentHttpClient,
9 primaryEnvironmentHttpClientLive,
10} from "../environments/primary/httpClient";
411
512export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch));
13
14const primaryHttpRuntime = ManagedRuntime.make(
15 primaryEnvironmentHttpClientLive.pipe(
16 Layer.provide(
17 Layer.mergeAll(
18 remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)),
19 Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }),
20 ),
21 ),
22 ),
23);
24
25export type PrimaryHttpEffectRunner = <A, E>(
26 effect: Effect.Effect<A, E, PrimaryEnvironmentHttpClient>,
27) => Promise<A>;
28
29const livePrimaryHttpRunner: PrimaryHttpEffectRunner = (effect) =>
30 primaryHttpRuntime.runPromise(effect);
31
32let primaryHttpRunner = livePrimaryHttpRunner;
33
34export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironmentHttpClient>) =>
35 primaryHttpRunner(effect);
36
37export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {
38 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
39}
apps/web/src/localApi.test.ts+3-3
......@@ -202,8 +202,8 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
202202 fetchSshSessionState: async () => {
203203 throw new Error("fetchSshSessionState not implemented in test");
204204 },
205 issueSshWebSocketToken: async () => {
206 throw new Error("issueSshWebSocketToken not implemented in test");
205 issueSshWebSocketTicket: async () => {
206 throw new Error("issueSshWebSocketTicket not implemented in test");
207207 },
208208 onSshPasswordPrompt: () => () => undefined,
209209 resolveSshPasswordPrompt: async () => undefined,
......@@ -289,7 +289,7 @@ const baseServerConfig: ServerConfig = {
289289 auth: {
290290 policy: "loopback-browser",
291291 bootstrapMethods: ["one-time-token"],
292 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
292 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
293293 sessionCookieName: "t3_session",
294294 },
295295 cwd: "/tmp/workspace",
apps/web/src/rpc/serverState.test.ts+1-1
......@@ -76,7 +76,7 @@ const baseServerConfig: ServerConfig = {
7676 auth: {
7777 policy: "loopback-browser",
7878 bootstrapMethods: ["one-time-token"],
79 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
79 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
8080 sessionCookieName: "t3_session",
8181 },
8282 cwd: "/tmp/workspace",
apps/web/test/authHttpHandlers.ts+77-15
......@@ -1,11 +1,27 @@
11import {
2 AuthSessionId,
3 EnvironmentAuthenticatedAuth,
4 EnvironmentAuthenticatedPrincipal,
5 EnvironmentAuthHttpApi,
26 EnvironmentId,
7 EnvironmentMetadataHttpApi,
8 type AuthEnvironmentScope,
39 type ExecutionEnvironmentDescriptor,
410 type ServerAuthDescriptor,
511} from "@t3tools/contracts";
6import { HttpResponse, http } from "msw";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { HttpRouter, HttpServer } from "effect/unstable/http";
16import * as HttpApi from "effect/unstable/httpapi/HttpApi";
17import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
18import { http } from "msw";
719
8const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z";
20const BrowserEnvironmentHttpApi = HttpApi.make("browserEnvironment")
21 .add(EnvironmentMetadataHttpApi)
22 .add(EnvironmentAuthHttpApi);
23
24const TEST_SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-05-01T12:00:00.000Z");
925const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = {
1026 environmentId: EnvironmentId.make("environment-local"),
1127 label: "Local environment",
......@@ -19,23 +35,69 @@ const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = {
1935 },
2036};
2137
38const unexpectedEndpoint = (endpoint: string) =>
39 Effect.die(new Error(`Unexpected browser environment HTTP endpoint: ${endpoint}`));
40
2241export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) {
23 return [
24 http.get("*/.well-known/t3/environment", () => HttpResponse.json(TEST_ENVIRONMENT_DESCRIPTOR)),
25 http.get("*/api/auth/session", () =>
26 HttpResponse.json({
27 authenticated: true,
28 auth: getAuthDescriptor(),
29 sessionMethod: "browser-session-cookie",
42 const authenticatedAuthLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) =>
43 httpEffect.pipe(
44 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
45 sessionId: AuthSessionId.make("browser-session"),
46 subject: "browser-client",
47 method: "browser-session-cookie",
48 scopes: new Set<AuthEnvironmentScope>(),
3049 expiresAt: TEST_SESSION_EXPIRES_AT,
3150 }),
3251 ),
33 http.post("*/api/auth/bootstrap", () =>
34 HttpResponse.json({
35 authenticated: true,
36 sessionMethod: "browser-session-cookie",
37 expiresAt: TEST_SESSION_EXPIRES_AT,
38 }),
52 );
53 const metadataLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "metadata", (handlers) =>
54 handlers.handle("descriptor", () => Effect.succeed(TEST_ENVIRONMENT_DESCRIPTOR)),
55 );
56 const authLayer = HttpApiBuilder.group(BrowserEnvironmentHttpApi, "auth", (handlers) =>
57 handlers
58 .handle("session", () =>
59 Effect.succeed({
60 authenticated: true,
61 auth: getAuthDescriptor(),
62 sessionMethod: "browser-session-cookie",
63 expiresAt: TEST_SESSION_EXPIRES_AT,
64 }),
65 )
66 .handle("browserSession", () =>
67 Effect.succeed({
68 authenticated: true,
69 scopes: [
70 "orchestration:read",
71 "orchestration:operate",
72 "terminal:operate",
73 "review:write",
74 "relay:read",
75 ],
76 sessionMethod: "browser-session-cookie",
77 expiresAt: TEST_SESSION_EXPIRES_AT,
78 }),
79 )
80 .handle("token", () => unexpectedEndpoint("auth.token"))
81 .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket"))
82 .handle("pairingCredential", () => unexpectedEndpoint("auth.pairingCredential"))
83 .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks"))
84 .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink"))
85 .handle("clients", () => unexpectedEndpoint("auth.clients"))
86 .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient"))
87 .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")),
88 ).pipe(Layer.provide(authenticatedAuthLayer));
89 const { handler } = HttpRouter.toWebHandler(
90 HttpApiBuilder.layer(BrowserEnvironmentHttpApi).pipe(
91 Layer.provide(metadataLayer),
92 Layer.provide(authLayer),
93 Layer.provide(authenticatedAuthLayer),
94 Layer.provide(HttpServer.layerServices),
3995 ),
96 { disableLogger: true },
97 );
98
99 return [
100 http.all("*/.well-known/t3/environment", ({ request }) => handler(request)),
101 http.all("*/api/auth/*", ({ request }) => handler(request)),
40102 ] as const;
41103}
apps/web/test/environmentHttpTest.ts created+136
......@@ -0,0 +1,136 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import {
3 AuthSessionId,
4 EnvironmentAuthenticatedAuth,
5 EnvironmentAuthenticatedPrincipal,
6 EnvironmentHttpApi,
7 type AuthBrowserSessionRequest,
8 type AuthBrowserSessionResult,
9 type AuthCreatePairingCredentialInput,
10 type AuthEnvironmentScope,
11 type AuthPairingCredentialResult,
12 type AuthSessionState,
13 type ExecutionEnvironmentDescriptor,
14 type EnvironmentAuthInvalidError,
15} from "@t3tools/contracts";
16import * as DateTime from "effect/DateTime";
17import type * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as ManagedRuntime from "effect/ManagedRuntime";
21import { HttpApiTest } from "effect/unstable/httpapi";
22import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
23
24import { PrimaryEnvironmentHttpClient } from "../src/environments/primary/httpClient";
25import { __setPrimaryHttpRunnerForTests } from "../src/lib/runtime";
26
27type BrowserSessionHandler = (
28 payload: AuthBrowserSessionRequest,
29) => Effect.Effect<AuthBrowserSessionResult, EnvironmentAuthInvalidError>;
30
31interface EnvironmentHttpTestScenario {
32 readonly descriptor?: () => Effect.Effect<ExecutionEnvironmentDescriptor>;
33 readonly session?: () => Effect.Effect<AuthSessionState>;
34 readonly browserSession?: BrowserSessionHandler;
35 readonly pairingCredential?: (
36 payload: AuthCreatePairingCredentialInput,
37 ) => Effect.Effect<AuthPairingCredentialResult>;
38}
39
40export interface EnvironmentHttpTestCalls {
41 descriptor: number;
42 session: number;
43 browserSession: Array<AuthBrowserSessionRequest>;
44 pairingCredential: Array<AuthCreatePairingCredentialInput>;
45}
46
47const unexpectedEndpoint = (endpoint: string) =>
48 Effect.die(new Error(`Unexpected environment HTTP endpoint: ${endpoint}`));
49
50const authenticatedAuth: Context.Service.Shape<typeof EnvironmentAuthenticatedAuth> = (
51 httpEffect,
52) =>
53 httpEffect.pipe(
54 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
55 sessionId: AuthSessionId.make("test-session"),
56 subject: "test-client",
57 method: "browser-session-cookie",
58 scopes: new Set<AuthEnvironmentScope>(),
59 expiresAt: DateTime.makeUnsafe("2026-05-01T12:00:00.000Z"),
60 }),
61 );
62
63export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestScenario) {
64 const calls: EnvironmentHttpTestCalls = {
65 descriptor: 0,
66 session: 0,
67 browserSession: [],
68 pairingCredential: [],
69 };
70
71 const client = await Effect.runPromise(
72 HttpApiTest.groups(EnvironmentHttpApi, ["metadata", "auth"]).pipe(
73 Effect.provide([
74 NodeHttpServer.layerHttpServices,
75 HttpApiBuilder.group(EnvironmentHttpApi, "metadata", (handlers) =>
76 handlers.handle(
77 "descriptor",
78 Effect.fn("test.environment.metadata.descriptor")(function* () {
79 calls.descriptor += 1;
80 return yield* scenario.descriptor?.() ?? unexpectedEndpoint("metadata.descriptor");
81 }),
82 ),
83 ),
84 HttpApiBuilder.group(EnvironmentHttpApi, "auth", (handlers) =>
85 handlers
86 .handle(
87 "session",
88 Effect.fn("test.environment.auth.session")(function* () {
89 calls.session += 1;
90 return yield* scenario.session?.() ?? unexpectedEndpoint("auth.session");
91 }),
92 )
93 .handle(
94 "browserSession",
95 Effect.fn("test.environment.auth.browserSession")(function* ({ payload }) {
96 calls.browserSession.push(payload);
97 return yield* (
98 scenario.browserSession?.(payload) ?? unexpectedEndpoint("auth.browserSession")
99 );
100 }),
101 )
102 .handle("token", () => unexpectedEndpoint("auth.token"))
103 .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket"))
104 .handle(
105 "pairingCredential",
106 Effect.fn("test.environment.auth.pairingCredential")(function* ({ payload }) {
107 calls.pairingCredential.push(payload);
108 return yield* (
109 scenario.pairingCredential?.(payload) ??
110 unexpectedEndpoint("auth.pairingCredential")
111 );
112 }),
113 )
114 .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks"))
115 .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink"))
116 .handle("clients", () => unexpectedEndpoint("auth.clients"))
117 .handle("revokeClient", () => unexpectedEndpoint("auth.revokeClient"))
118 .handle("revokeOtherClients", () => unexpectedEndpoint("auth.revokeOtherClients")),
119 ),
120 ]),
121 Effect.provideService(EnvironmentAuthenticatedAuth, authenticatedAuth),
122 Effect.scoped,
123 ),
124 );
125
126 const runtime = ManagedRuntime.make(Layer.succeed(PrimaryEnvironmentHttpClient, client));
127 __setPrimaryHttpRunnerForTests((effect) => runtime.runPromise(effect));
128
129 return {
130 calls,
131 async dispose() {
132 __setPrimaryHttpRunnerForTests();
133 await runtime.dispose();
134 },
135 };
136}
bun.lock+5-2
......@@ -20,6 +20,7 @@
2020 "version": "0.0.24",
2121 "dependencies": {
2222 "@effect/platform-node": "catalog:",
23 "@t3tools/client-runtime": "workspace:*",
2324 "@t3tools/contracts": "workspace:*",
2425 "@t3tools/shared": "workspace:*",
2526 "@t3tools/ssh": "workspace:*",
......@@ -179,6 +180,7 @@
179180 "zustand": "^5.0.11",
180181 },
181182 "devDependencies": {
183 "@effect/platform-node": "catalog:",
182184 "@rolldown/plugin-babel": "^0.2.0",
183185 "@tailwindcss/vite": "^4.0.0",
184186 "@tanstack/router-plugin": "^1.161.0",
......@@ -187,7 +189,7 @@
187189 "@types/react-dom": "~19.2.3",
188190 "@vercel/config": "^0.3.0",
189191 "@vitejs/plugin-react": "^6.0.0",
190 "@vitest/browser-playwright": "^4.0.18",
192 "@vitest/browser-playwright": "catalog:",
191193 "babel-plugin-react-compiler": "1.0.0",
192194 "msw": "2.12.11",
193195 "playwright": "^1.58.2",
......@@ -345,10 +347,11 @@
345347 "@types/bun": "^1.3.11",
346348 "@types/node": "^24.10.13",
347349 "@typescript/native-preview": "7.0.0-dev.20260527.2",
350 "@vitest/browser-playwright": "4.1.0",
348351 "effect": "4.0.0-beta.73",
349352 "tsdown": "^0.20.3",
350353 "typescript": "~6.0.3",
351 "vitest": "^4.0.0",
354 "vitest": "4.1.0",
352355 },
353356 "packages": {
354357 "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
docs/environment-auth.md created+113
......@@ -0,0 +1,113 @@
1# Environment Authentication Profile
2
3The environment server and the relay use separate credentials, issuers, and trust
4boundaries. They intentionally use a similar OAuth-shaped model so that permission
5checks and token exchange behavior can be audited against established concepts.
6
7## Authorization Model
8
9Environment authorization is capability-based. A session carries zero or more
10OAuth-style scope strings:
11
12| Scope | Permission |
13| ----------------------- | ------------------------------------------------------------------------ |
14| `orchestration:read` | Read snapshots, status, events, configuration, and filesystem/VCS state. |
15| `orchestration:operate` | Dispatch user operations and mutate environment-side workspace state. |
16| `terminal:operate` | Create, attach, input, resize, clear, restart, and terminate terminals. |
17| `review:write` | Read review diff previews used to compose review feedback. |
18| `access:read` | Inspect pairing links and client sessions. |
19| `access:write` | Create or revoke pairing links and client sessions. |
20| `relay:read` | Inspect managed relay connectivity. |
21| `relay:write` | Link, configure, or unlink managed relay connectivity. |
22
23Ordinary pairing links grant the four client-operation scopes and read access to
24managed relay connectivity:
25`orchestration:read orchestration:operate terminal:operate review:write relay:read`.
26The desktop bootstrap credential and command-line administrative bootstrap
27credentials additionally grant `access:read access:write relay:write`.
28
29## Authentication Flows
30
31### Browser Session
32
33`POST /api/auth/browser-session` consumes a one-time bootstrap credential and creates a
34browser session cookie. The cookie is an HTTP transport adapter for the same
35scoped session model; the response never exposes the session secret to browser
36JavaScript.
37
38### Bearer Access Token
39
40Non-browser clients use `POST /oauth/token` with an
41`application/x-www-form-urlencoded` body:
42
43```text
44grant_type=urn:ietf:params:oauth:grant-type:token-exchange
45subject_token=<bootstrap credential>
46subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap
47requested_token_type=urn:ietf:params:oauth:token-type:access_token
48scope=orchestration:read orchestration:operate terminal:operate review:write relay:read
49```
50
51Clients may additionally submit `client_label`, `client_device_type`, and
52`client_os` extension parameters so the authorized-clients UI can identify the
53device that established the session. These are presentation hints only; the
54environment derives transport metadata such as IP address and user agent from
55the request and does not use these fields for authorization.
56
57The response has the token-exchange shape:
58
59```json
60{
61 "access_token": "<opaque session token>",
62 "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
63 "token_type": "Bearer",
64 "expires_in": 3600,
65 "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read"
66}
67```
68
69Requested scopes must be a subset of the one-time bootstrap credential grant.
70An ordinary paired client therefore cannot exchange its grant for
71`access:read`, `access:write`, or `relay:write`.
72
73### WebSocket Ticket
74
75`POST /api/auth/websocket-ticket` accepts any authenticated session and returns
76a short-lived, single-purpose WebSocket ticket. This keeps bearer tokens and
77browser cookies out of WebSocket URLs while allowing the socket handshake to
78authenticate. The ticket carries its session's scopes; each RPC method then
79enforces `orchestration:read`, `orchestration:operate`, `terminal:operate`,
80`review:write`, or `access:read` as appropriate. Review feedback submission
81currently dispatches an orchestration operation, so clients performing it also
82need `orchestration:operate`. Creating a ticket is not
83authorization to call every RPC method.
84
85## Standards Alignment
86
87- Bearer access tokens are used through the `Authorization: Bearer` scheme from
88 RFC 6750.
89- The token endpoint profiles the request and response vocabulary from OAuth 2.0
90 Token Exchange (RFC 8693), including `subject_token`, `requested_token_type`,
91 `access_token`, `issued_token_type`, and `token_type`.
92- Scope values follow the OAuth 2.0 scope model from RFC 6749: space-delimited,
93 unordered capabilities with subset checking during exchange.
94
95This is intentionally not a general-purpose OAuth authorization server. The
96environment bootstrap token type is private, the bootstrap cookie and WebSocket
97connection-token routes are product-specific adapters, and the API returns its
98typed `HttpApi` errors rather than implementing every OAuth error response
99surface.
100
101## Upgrade Behavior
102
103Migration `031_AuthAuthorizationScopes` is a hard cutover from role-bearing auth
104records to scoped records. It deletes existing pairing links and sessions while
105leaving non-authentication environment state unchanged. Upgraded clients must
106pair again; old `owner` or `client` credentials are never silently mapped to new
107capabilities.
108
109## Relay Boundary
110
111Relay-managed tunnels use their own tokens and keys. The relay can reuse scope
112parsing and token-exchange conventions, but an environment access token is not a
113relay token and cannot be presented to the relay.
package.json+2-1
......@@ -24,7 +24,8 @@
2424 "@typescript/native-preview": "7.0.0-dev.20260527.2",
2525 "tsdown": "^0.20.3",
2626 "typescript": "~6.0.3",
27 "vitest": "^4.0.0"
27 "@vitest/browser-playwright": "4.1.0",
28 "vitest": "4.1.0"
2829 }
2930 },
3031 "type": "module",
packages/client-runtime/src/remote.test.ts+167-32
......@@ -2,19 +2,23 @@ import { describe, expect, it } from "@effect/vitest";
22import * as Duration from "effect/Duration";
33import * as Effect from "effect/Effect";
44import * as Fiber from "effect/Fiber";
5import * as Schema from "effect/Schema";
56import * as TestClock from "effect/testing/TestClock";
6import { HttpClient } from "effect/unstable/http";
77
8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
89import {
910 bootstrapRemoteBearerSession,
1011 fetchRemoteEnvironmentDescriptor,
1112 fetchRemoteSessionState,
12 issueRemoteWebSocketToken,
13 issueRemoteWebSocketTicket,
1314 remoteHttpClientLayer,
15 RemoteEnvironmentAuthInvalidJsonError,
1416 RemoteEnvironmentAuthTimeoutError,
1517 resolveRemoteWebSocketConnectionUrl,
1618} from "./remote.ts";
1719
20const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
21
1822type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];
1923
2024const recordedFetch = (...responses: ReadonlyArray<Response>) => {
......@@ -42,10 +46,7 @@ const hangingFetch = () => {
4246 return { fetchFn, calls };
4347};
4448
45const provideRemoteHttp =
46 (fetchFn: typeof fetch) =>
47 <A, E, R>(effect: Effect.Effect<A, E, R | HttpClient.HttpClient>) =>
48 effect.pipe(Effect.provide(remoteHttpClientLayer(fetchFn)));
49const provideRemoteHttp = (fetchFn: typeof fetch) => Effect.provide(remoteHttpClientLayer(fetchFn));
4950
5051const expectFetchCall = (
5152 calls: ReadonlyArray<FetchCall>,
......@@ -90,11 +91,12 @@ describe("remote", () => {
9091 const fetch = recordedFetch(
9192 Response.json(
9293 {
93 authenticated: true,
94 role: "client",
95 sessionMethod: "bearer-session-token",
96 expiresAt: "2026-05-01T12:00:00.000Z",
97 sessionToken: "bearer-token",
94 access_token: "bearer-token",
95 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
96 token_type: "Bearer",
97 expires_in: 3600,
98 scope:
99 "orchestration:read orchestration:operate terminal:operate review:write relay:read",
98100 },
99101 { status: 200 },
100102 ),
......@@ -106,16 +108,80 @@ describe("remote", () => {
106108 }).pipe(provideRemoteHttp(fetch.fetchFn));
107109
108110 expect(result).toMatchObject({
109 sessionMethod: "bearer-session-token",
110 sessionToken: "bearer-token",
111 token_type: "Bearer",
112 access_token: "bearer-token",
113 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
111114 });
112115 expectFetchCall(fetch.calls, 1, {
113 url: "https://remote.example.com/api/auth/bootstrap/bearer",
116 url: "https://remote.example.com/oauth/token",
114117 method: "POST",
115118 headers: {
116 "content-type": "application/json",
119 "content-type": "application/x-www-form-urlencoded",
117120 },
118 body: `{"credential":"pairing-token"}`,
121 body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
122 });
123 }),
124 );
125
126 it.effect("submits optional client display metadata during bearer token exchange", () =>
127 Effect.gen(function* () {
128 const fetch = recordedFetch(
129 Response.json(
130 {
131 access_token: "bearer-token",
132 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
133 token_type: "Bearer",
134 expires_in: 3600,
135 scope:
136 "orchestration:read orchestration:operate terminal:operate review:write relay:read",
137 },
138 { status: 200 },
139 ),
140 );
141
142 yield* bootstrapRemoteBearerSession({
143 httpBaseUrl: "https://remote.example.com/",
144 credential: "pairing-token",
145 clientMetadata: {
146 label: "T3 Code Mobile",
147 deviceType: "mobile",
148 os: "iOS",
149 },
150 }).pipe(provideRemoteHttp(fetch.fetchFn));
151
152 expectFetchCall(fetch.calls, 1, {
153 url: "https://remote.example.com/oauth/token",
154 method: "POST",
155 body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS",
156 });
157 }),
158 );
159
160 it.effect("allows a client to explicitly narrow a pairing grant", () =>
161 Effect.gen(function* () {
162 const fetch = recordedFetch(
163 Response.json(
164 {
165 access_token: "read-only-token",
166 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
167 token_type: "Bearer",
168 expires_in: 3600,
169 scope: "orchestration:read",
170 },
171 { status: 200 },
172 ),
173 );
174
175 yield* bootstrapRemoteBearerSession({
176 httpBaseUrl: "https://remote.example.com/",
177 credential: "pairing-token",
178 scopes: ["orchestration:read"],
179 }).pipe(provideRemoteHttp(fetch.fetchFn));
180
181 expectFetchCall(fetch.calls, 1, {
182 url: "https://remote.example.com/oauth/token",
183 method: "POST",
184 body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=pairing-token&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=orchestration%3Aread",
119185 });
120186 }),
121187 );
......@@ -144,18 +210,24 @@ describe("remote", () => {
144210 auth: {
145211 policy: "remote-reachable",
146212 bootstrapMethods: ["one-time-token"],
147 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
213 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
148214 sessionCookieName: "t3_session",
149215 },
150 role: "client",
151 sessionMethod: "bearer-session-token",
216 scopes: [
217 "orchestration:read",
218 "orchestration:operate",
219 "terminal:operate",
220 "review:write",
221 "relay:read",
222 ],
223 sessionMethod: "bearer-access-token",
152224 expiresAt: "2026-05-01T12:00:00.000Z",
153225 },
154226 { status: 200 },
155227 ),
156228 Response.json(
157229 {
158 token: "ws-token",
230 ticket: "ws-ticket",
159231 expiresAt: "2026-05-01T12:05:00.000Z",
160232 },
161233 { status: 200 },
......@@ -176,15 +248,21 @@ describe("remote", () => {
176248 }).pipe(provideRemoteHttp(fetch.fetchFn));
177249 expect(session).toMatchObject({
178250 authenticated: true,
179 role: "client",
251 scopes: [
252 "orchestration:read",
253 "orchestration:operate",
254 "terminal:operate",
255 "review:write",
256 "relay:read",
257 ],
180258 });
181259
182 const token = yield* issueRemoteWebSocketToken({
260 const token = yield* issueRemoteWebSocketTicket({
183261 httpBaseUrl: "https://remote.example.com/",
184262 bearerToken: "bearer-token",
185263 }).pipe(provideRemoteHttp(fetch.fetchFn));
186264 expect(token).toMatchObject({
187 token: "ws-token",
265 ticket: "ws-ticket",
188266 });
189267
190268 expectFetchCall(fetch.calls, 1, {
......@@ -199,7 +277,7 @@ describe("remote", () => {
199277 },
200278 });
201279 expectFetchCall(fetch.calls, 3, {
202 url: "https://remote.example.com/api/auth/ws-token",
280 url: "https://remote.example.com/api/auth/websocket-ticket",
203281 method: "POST",
204282 headers: {
205283 authorization: "Bearer bearer-token",
......@@ -227,25 +305,82 @@ describe("remote", () => {
227305 }).pipe(Effect.provide(TestClock.layer())),
228306 );
229307
230 it.effect("mints a websocket url that targets the rpc route with a short-lived ws token", () =>
308 it.effect("revives declared typed errors from remote auth failures", () =>
231309 Effect.gen(function* () {
232310 const fetch = recordedFetch(
233311 Response.json(
234312 {
235 token: "ws-token",
236 expiresAt: "2026-05-01T12:05:00.000Z",
313 _tag: "EnvironmentAuthInvalidError",
314 code: "auth_invalid",
315 reason: "missing_credential",
316 traceId: "trace-auth-test",
317 },
318 { status: 401 },
319 ),
320 );
321
322 const error = yield* issueRemoteWebSocketTicket({
323 httpBaseUrl: "https://remote.example.com/",
324 bearerToken: "expired-token",
325 }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip);
326
327 expect(isEnvironmentAuthInvalidError(error)).toBe(true);
328 if (isEnvironmentAuthInvalidError(error)) {
329 expect(error.reason).toBe("missing_credential");
330 expect(error.traceId).toBe("trace-auth-test");
331 }
332 }),
333 );
334
335 it.effect("classifies malformed successful remote auth responses as invalid responses", () =>
336 Effect.gen(function* () {
337 const fetch = recordedFetch(
338 Response.json(
339 {
340 access_token: "",
341 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
342 token_type: "Bearer",
343 expires_in: 3600,
344 scope:
345 "orchestration:read orchestration:operate terminal:operate review:write relay:read",
237346 },
238347 { status: 200 },
239348 ),
240349 );
241350
242 const url = yield* resolveRemoteWebSocketConnectionUrl({
243 wsBaseUrl: "wss://remote.example.com/",
351 const error = yield* bootstrapRemoteBearerSession({
244352 httpBaseUrl: "https://remote.example.com/",
245 bearerToken: "bearer-token",
246 }).pipe(provideRemoteHttp(fetch.fetchFn));
353 credential: "pairing-token",
354 }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip);
247355
248 expect(url).toBe("wss://remote.example.com/ws?wsToken=ws-token");
356 expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError);
357 expect(error.message).toBe(
358 "Remote auth endpoint returned an invalid response from https://remote.example.com/oauth/token.",
359 );
249360 }),
250361 );
362
363 it.effect(
364 "mints a websocket url that targets the rpc route with a short-lived websocket ticket",
365 () =>
366 Effect.gen(function* () {
367 const fetch = recordedFetch(
368 Response.json(
369 {
370 ticket: "ws-ticket",
371 expiresAt: "2026-05-01T12:05:00.000Z",
372 },
373 { status: 200 },
374 ),
375 );
376
377 const url = yield* resolveRemoteWebSocketConnectionUrl({
378 wsBaseUrl: "wss://remote.example.com/",
379 httpBaseUrl: "https://remote.example.com/",
380 bearerToken: "bearer-token",
381 }).pipe(provideRemoteHttp(fetch.fetchFn));
382
383 expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket");
384 }),
385 );
251386});
packages/client-runtime/src/remote.ts+145-142
......@@ -1,30 +1,31 @@
1import {
2 AuthAccessTokenType,
3 type AuthClientPresentationMetadata,
4 AuthEnvironmentBootstrapTokenType,
5 AuthTokenExchangeGrantType,
6 type AuthEnvironmentScope,
7 EnvironmentHttpApi,
8 EnvironmentHttpCommonError,
9} from "@t3tools/contracts";
10import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
111import type {
2 AuthBearerBootstrapResult,
3 AuthSessionState,
4 AuthWebSocketTokenResult,
5 ExecutionEnvironmentDescriptor,
12 EnvironmentAuthInvalidError,
13 EnvironmentInternalError,
14 EnvironmentOperationForbiddenError,
15 EnvironmentRequestInvalidError,
16 EnvironmentScopeRequiredError,
617} from "@t3tools/contracts";
7import { decodeJsonResult } from "@t3tools/shared/schemaJson";
818import * as Data from "effect/Data";
919import * as Duration from "effect/Duration";
1020import * as Effect from "effect/Effect";
1121import * as Layer from "effect/Layer";
1222import * as Option from "effect/Option";
13import * as Result from "effect/Result";
1423import * as Schema from "effect/Schema";
15import { identity } from "effect/Function";
16import {
17 FetchHttpClient,
18 HttpClient,
19 HttpClientRequest,
20 HttpClientResponse,
21} from "effect/unstable/http";
24import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http";
25import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
2226
2327const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
24const RemoteAuthErrorBody = Schema.Struct({
25 error: Schema.optional(Schema.String),
26});
27const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody);
28const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
2829
2930const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
3031 const url = new URL(httpBaseUrl);
......@@ -34,6 +35,14 @@ const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
3435 return url.toString();
3536};
3637
38const remoteApiBaseUrl = (httpBaseUrl: string): string => {
39 const url = new URL(httpBaseUrl);
40 url.pathname = "/";
41 url.search = "";
42 url.hash = "";
43 return url.toString();
44};
45
3746export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
3847 "RemoteEnvironmentAuthFetchError",
3948)<{
......@@ -41,13 +50,6 @@ export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
4150 readonly cause: unknown;
4251}> {}
4352
44export class RemoteEnvironmentAuthResponseReadError extends Data.TaggedError(
45 "RemoteEnvironmentAuthResponseReadError",
46)<{
47 readonly message: string;
48 readonly cause: unknown;
49}> {}
50
5153export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
5254 "RemoteEnvironmentAuthInvalidJsonError",
5355)<{
......@@ -55,14 +57,19 @@ export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
5557 readonly cause: unknown;
5658}> {}
5759
58export class RemoteEnvironmentAuthHttpError extends Data.TaggedError(
59 "RemoteEnvironmentAuthHttpError",
60export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError(
61 "RemoteEnvironmentAuthUndeclaredStatusError",
6062)<{
6163 readonly message: string;
6264 readonly status: number;
65 readonly requestUrl: string;
6366}> {
64 constructor(message: string, status: number) {
65 super({ message, status });
67 constructor(requestUrl: string, status: number) {
68 super({
69 message: `Remote auth endpoint ${requestUrl} returned undeclared status ${status}.`,
70 requestUrl,
71 status,
72 });
6673 }
6774}
6875
......@@ -83,87 +90,67 @@ export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError(
8390}
8491
8592export type RemoteEnvironmentAuthError =
93 | EnvironmentRequestInvalidError
94 | EnvironmentAuthInvalidError
95 | EnvironmentScopeRequiredError
96 | EnvironmentOperationForbiddenError
97 | EnvironmentInternalError
8698 | RemoteEnvironmentAuthFetchError
87 | RemoteEnvironmentAuthResponseReadError
8899 | RemoteEnvironmentAuthInvalidJsonError
89 | RemoteEnvironmentAuthHttpError
100 | RemoteEnvironmentAuthUndeclaredStatusError
90101 | RemoteEnvironmentAuthTimeoutError;
91102
92export const isRemoteEnvironmentAuthHttpError = (
93 error: unknown,
94): error is RemoteEnvironmentAuthHttpError => error instanceof RemoteEnvironmentAuthHttpError;
95
96const readRemoteAuthErrorMessage = (
97 response: HttpClientResponse.HttpClientResponse,
98 fallbackMessage: string,
99): Effect.Effect<string, RemoteEnvironmentAuthResponseReadError> =>
100 response.text.pipe(
101 Effect.mapError(
102 (cause) =>
103 new RemoteEnvironmentAuthResponseReadError({
104 message: "Remote auth endpoint returned an unreadable error response.",
105 cause,
106 }),
107 ),
108 Effect.map((text) => {
109 if (!text) {
110 return fallbackMessage;
111 }
112
113 const decoded = decodeRemoteAuthErrorBody(text);
114 if (Result.isSuccess(decoded) && decoded.success.error) {
115 return decoded.success.error;
116 }
117
118 return text;
119 }),
120 );
121
122const readRemoteJson = <T>(
123 response: HttpClientResponse.HttpClientResponse,
124 requestUrl: string,
125): Effect.Effect<T, RemoteEnvironmentAuthInvalidJsonError> =>
126 response.json.pipe(
127 Effect.mapError(
128 (cause) =>
129 new RemoteEnvironmentAuthInvalidJsonError({
130 message: `Remote auth endpoint returned invalid JSON from ${requestUrl}.`,
131 cause,
132 }),
133 ),
134 Effect.map((value) => value as T),
135 );
136
137103export const remoteHttpClientLayer = (
138104 fetchFn: typeof globalThis.fetch,
139105): Layer.Layer<HttpClient.HttpClient> =>
140106 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)));
141107
142const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(function* <T>(input: {
143 readonly httpBaseUrl: string;
144 readonly pathname: string;
145 readonly method?: "GET" | "POST";
146 readonly bearerToken?: string;
147 readonly body?: unknown;
148 readonly timeoutMs?: number;
149}) {
150 const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname);
151 const method = input.method ?? "GET";
152 const timeoutMs = input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS;
153 const request = HttpClientRequest.make(method)(requestUrl).pipe(
154 input.bearerToken ? HttpClientRequest.bearerToken(input.bearerToken) : identity,
155 input.body !== undefined ? HttpClientRequest.bodyJsonUnsafe(input.body) : identity,
108const failRemoteRequest = (
109 requestUrl: string,
110 cause: unknown,
111): Effect.Effect<never, RemoteEnvironmentAuthError> => {
112 if (cause instanceof RemoteEnvironmentAuthTimeoutError) {
113 return Effect.fail(cause);
114 }
115 if (isEnvironmentHttpCommonError(cause)) {
116 return Effect.fail(cause);
117 }
118 if (Schema.isSchemaError(cause)) {
119 return Effect.fail(
120 new RemoteEnvironmentAuthInvalidJsonError({
121 message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`,
122 cause,
123 }),
124 );
125 }
126 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
127 const response = cause.response;
128 if (response.status < 200 || response.status >= 300) {
129 return Effect.fail(
130 new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status),
131 );
132 }
133 return Effect.fail(
134 new RemoteEnvironmentAuthInvalidJsonError({
135 message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`,
136 cause,
137 }),
138 );
139 }
140 return Effect.fail(
141 new RemoteEnvironmentAuthFetchError({
142 message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`,
143 cause,
144 }),
156145 );
146};
157147
158 const client = yield* HttpClient.HttpClient;
159 const response = yield* client.execute(request).pipe(
160 Effect.mapError(
161 (cause) =>
162 new RemoteEnvironmentAuthFetchError({
163 message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`,
164 cause,
165 }),
166 ),
148const executeRemoteRequest = <A, E, R>(
149 requestUrl: string,
150 timeoutMs: number,
151 request: Effect.Effect<A, E, R>,
152): Effect.Effect<A, RemoteEnvironmentAuthError, R> =>
153 request.pipe(
167154 Effect.timeoutOption(Duration.millis(timeoutMs)),
168155 Effect.flatMap(
169156 Option.match({
......@@ -171,38 +158,46 @@ const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(functi
171158 onSome: Effect.succeed,
172159 }),
173160 ),
161 Effect.catch((cause) => failRemoteRequest(requestUrl, cause)),
174162 );
175163
176 if (response.status < 200 || response.status >= 300) {
177 return yield* readRemoteAuthErrorMessage(
178 response,
179 `Remote auth request failed (${response.status}).`,
180 ).pipe(
181 Effect.flatMap((message) =>
182 Effect.fail(new RemoteEnvironmentAuthHttpError(message, response.status)),
183 ),
184 );
185 }
186
187 return yield* readRemoteJson<T>(response, requestUrl);
188});
164export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
165 Effect.gen(function* () {
166 const httpClient = yield* HttpClient.HttpClient;
167 return yield* HttpApiClient.makeWith(EnvironmentHttpApi, {
168 httpClient,
169 baseUrl: remoteApiBaseUrl(httpBaseUrl),
170 });
171 });
189172
190173export const bootstrapRemoteBearerSession = Effect.fn(
191174 "clientRuntime.remote.bootstrapRemoteBearerSession",
192175)(function* (input: {
193176 readonly httpBaseUrl: string;
194177 readonly credential: string;
178 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
179 readonly clientMetadata?: AuthClientPresentationMetadata;
195180 readonly timeoutMs?: number;
196181}) {
197 return yield* fetchRemoteJson<AuthBearerBootstrapResult>({
198 httpBaseUrl: input.httpBaseUrl,
199 pathname: "/api/auth/bootstrap/bearer",
200 method: "POST",
201 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
202 body: {
203 credential: input.credential,
204 },
205 });
182 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
183 return yield* executeRemoteRequest(
184 remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"),
185 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
186 client.auth.token({
187 payload: {
188 grant_type: AuthTokenExchangeGrantType,
189 subject_token: input.credential,
190 subject_token_type: AuthEnvironmentBootstrapTokenType,
191 requested_token_type: AuthAccessTokenType,
192 ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}),
193 ...(input.clientMetadata?.label ? { client_label: input.clientMetadata.label } : {}),
194 ...(input.clientMetadata?.deviceType
195 ? { client_device_type: input.clientMetadata.deviceType }
196 : {}),
197 ...(input.clientMetadata?.os ? { client_os: input.clientMetadata.os } : {}),
198 },
199 }),
200 );
206201});
207202
208203export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")(
......@@ -211,39 +206,47 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo
211206 readonly bearerToken: string;
212207 readonly timeoutMs?: number;
213208 }) {
214 return yield* fetchRemoteJson<AuthSessionState>({
215 httpBaseUrl: input.httpBaseUrl,
216 pathname: "/api/auth/session",
217 bearerToken: input.bearerToken,
218 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
219 });
209 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
210 return yield* executeRemoteRequest(
211 remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"),
212 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
213 client.auth.session({
214 headers: {
215 authorization: `Bearer ${input.bearerToken}`,
216 },
217 }),
218 );
220219 },
221220);
222221
223222export const fetchRemoteEnvironmentDescriptor = Effect.fn(
224223 "clientRuntime.remote.fetchRemoteEnvironmentDescriptor",
225224)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) {
226 return yield* fetchRemoteJson<ExecutionEnvironmentDescriptor>({
227 httpBaseUrl: input.httpBaseUrl,
228 pathname: "/.well-known/t3/environment",
229 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
230 });
225 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
226 return yield* executeRemoteRequest(
227 remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"),
228 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
229 client.metadata.descriptor(),
230 );
231231});
232232
233export const issueRemoteWebSocketToken = Effect.fn(
234 "clientRuntime.remote.issueRemoteWebSocketToken",
233export const issueRemoteWebSocketTicket = Effect.fn(
234 "clientRuntime.remote.issueRemoteWebSocketTicket",
235235)(function* (input: {
236236 readonly httpBaseUrl: string;
237237 readonly bearerToken: string;
238238 readonly timeoutMs?: number;
239239}) {
240 return yield* fetchRemoteJson<AuthWebSocketTokenResult>({
241 httpBaseUrl: input.httpBaseUrl,
242 pathname: "/api/auth/ws-token",
243 method: "POST",
244 bearerToken: input.bearerToken,
245 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
246 });
240 const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl);
241 return yield* executeRemoteRequest(
242 remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"),
243 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
244 client.auth.webSocketTicket({
245 headers: {
246 authorization: `Bearer ${input.bearerToken}`,
247 },
248 }),
249 );
247250});
248251
249252export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
......@@ -254,7 +257,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
254257 readonly bearerToken: string;
255258 readonly timeoutMs?: number;
256259}) {
257 const issued = yield* issueRemoteWebSocketToken({
260 const issued = yield* issueRemoteWebSocketTicket({
258261 httpBaseUrl: input.httpBaseUrl,
259262 bearerToken: input.bearerToken,
260263 ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}),
......@@ -264,6 +267,6 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
264267 if (url.pathname === "" || url.pathname === "/") {
265268 url.pathname = "/ws";
266269 }
267 url.searchParams.set("wsToken", issued.token);
270 url.searchParams.set("wsTicket", issued.ticket);
268271 return url.toString();
269272});
packages/client-runtime/src/wsTransport.test.ts+2-2
......@@ -170,13 +170,13 @@ describe("WsTransport", () => {
170170 });
171171
172172 it("supports async websocket url providers", async () => {
173 const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic");
173 const transport = createTransport(async () => "wss://remote.example.com/?wsTicket=dynamic");
174174
175175 await waitFor(() => {
176176 expect(sockets).toHaveLength(1);
177177 });
178178
179 expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic");
179 expect(getSocket().url).toBe("wss://remote.example.com/ws?wsTicket=dynamic");
180180 await transport.dispose();
181181 });
182182
packages/contracts/src/auth.ts+106-31
......@@ -1,4 +1,5 @@
11import * as Schema from "effect/Schema";
2import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
23
34import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";
45
......@@ -60,17 +61,56 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type;
6061 * Current methods:
6162 * - `browser-session-cookie`: cookie-backed browser session, used by the web
6263 * app after bootstrap/pairing
63 * - `bearer-session-token`: token-based session suitable for non-cookie or
64 * - `bearer-access-token`: scoped token suitable for non-cookie or
6465 * non-browser clients
6566 */
6667export const ServerAuthSessionMethod = Schema.Literals([
6768 "browser-session-cookie",
68 "bearer-session-token",
69 "bearer-access-token",
6970]);
7071export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
7172
72export const AuthSessionRole = Schema.Literals(["owner", "client"]);
73export type AuthSessionRole = typeof AuthSessionRole.Type;
73export const AuthOrchestrationReadScope = "orchestration:read" as const;
74export const AuthOrchestrationOperateScope = "orchestration:operate" as const;
75export const AuthTerminalOperateScope = "terminal:operate" as const;
76export const AuthReviewWriteScope = "review:write" as const;
77export const AuthAccessReadScope = "access:read" as const;
78export const AuthAccessWriteScope = "access:write" as const;
79export const AuthRelayReadScope = "relay:read" as const;
80export const AuthRelayWriteScope = "relay:write" as const;
81export const AuthEnvironmentScope = Schema.Literals([
82 AuthOrchestrationReadScope,
83 AuthOrchestrationOperateScope,
84 AuthTerminalOperateScope,
85 AuthReviewWriteScope,
86 AuthAccessReadScope,
87 AuthAccessWriteScope,
88 AuthRelayReadScope,
89 AuthRelayWriteScope,
90]);
91export type AuthEnvironmentScope = typeof AuthEnvironmentScope.Type;
92export const AuthEnvironmentScopes = Schema.Array(AuthEnvironmentScope);
93export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;
94
95export const AuthStandardClientScopes = [
96 AuthOrchestrationReadScope,
97 AuthOrchestrationOperateScope,
98 AuthTerminalOperateScope,
99 AuthReviewWriteScope,
100 AuthRelayReadScope,
101] as const;
102export const AuthAdministrativeScopes = [
103 ...AuthStandardClientScopes,
104 AuthAccessReadScope,
105 AuthAccessWriteScope,
106 AuthRelayWriteScope,
107] as const;
108
109export const AuthTokenExchangeGrantType =
110 "urn:ietf:params:oauth:grant-type:token-exchange" as const;
111export const AuthAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const;
112export const AuthEnvironmentBootstrapTokenType =
113 "urn:t3:params:oauth:token-type:environment-bootstrap" as const;
74114
75115/**
76116 * Server-advertised auth capabilities for a specific execution environment.
......@@ -100,33 +140,61 @@ export const ServerAuthDescriptor = Schema.Struct({
100140});
101141export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type;
102142
103export const AuthBootstrapInput = Schema.Struct({
143export const AuthBrowserSessionRequest = Schema.Struct({
104144 credential: TrimmedNonEmptyString,
105145});
106export type AuthBootstrapInput = typeof AuthBootstrapInput.Type;
146export type AuthBrowserSessionRequest = typeof AuthBrowserSessionRequest.Type;
107147
108export const AuthBootstrapResult = Schema.Struct({
148export const AuthBrowserSessionResult = Schema.Struct({
109149 authenticated: Schema.Literal(true),
110 role: AuthSessionRole,
150 scopes: AuthEnvironmentScopes,
111151 sessionMethod: ServerAuthSessionMethod,
112152 expiresAt: Schema.DateTimeUtc,
113153});
114export type AuthBootstrapResult = typeof AuthBootstrapResult.Type;
154export type AuthBrowserSessionResult = typeof AuthBrowserSessionResult.Type;
115155
116export const AuthBearerBootstrapResult = Schema.Struct({
117 authenticated: Schema.Literal(true),
118 role: AuthSessionRole,
119 sessionMethod: Schema.Literal("bearer-session-token"),
120 expiresAt: Schema.DateTimeUtc,
121 sessionToken: TrimmedNonEmptyString,
156export const AuthClientMetadataDeviceType = Schema.Literals([
157 "desktop",
158 "mobile",
159 "tablet",
160 "bot",
161 "unknown",
162]);
163export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type;
164
165export const AuthClientPresentationMetadata = Schema.Struct({
166 label: Schema.optionalKey(TrimmedNonEmptyString),
167 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),
168 os: Schema.optionalKey(TrimmedNonEmptyString),
169});
170export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;
171
172export const AuthTokenExchangeRequest = Schema.Struct({
173 grant_type: Schema.Literal(AuthTokenExchangeGrantType),
174 subject_token: TrimmedNonEmptyString,
175 subject_token_type: Schema.Literal(AuthEnvironmentBootstrapTokenType),
176 requested_token_type: Schema.Literal(AuthAccessTokenType),
177 scope: Schema.optionalKey(TrimmedNonEmptyString),
178 client_label: Schema.optionalKey(TrimmedNonEmptyString),
179 client_device_type: Schema.optionalKey(AuthClientMetadataDeviceType),
180 client_os: Schema.optionalKey(TrimmedNonEmptyString),
181}).pipe(HttpApiSchema.asFormUrlEncoded());
182export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type;
183
184export const AuthAccessTokenResult = Schema.Struct({
185 access_token: TrimmedNonEmptyString,
186 issued_token_type: Schema.Literal(AuthAccessTokenType),
187 token_type: Schema.Literal("Bearer"),
188 expires_in: Schema.Number,
189 scope: TrimmedNonEmptyString,
122190});
123export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type;
191export type AuthAccessTokenResult = typeof AuthAccessTokenResult.Type;
124192
125export const AuthWebSocketTokenResult = Schema.Struct({
126 token: TrimmedNonEmptyString,
193export const AuthWebSocketTicketResult = Schema.Struct({
194 ticket: TrimmedNonEmptyString,
127195 expiresAt: Schema.DateTimeUtc,
128196});
129export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type;
197export type AuthWebSocketTicketResult = typeof AuthWebSocketTicketResult.Type;
130198
131199export const AuthPairingCredentialResult = Schema.Struct({
132200 id: TrimmedNonEmptyString,
......@@ -139,7 +207,7 @@ export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Typ
139207export const AuthPairingLink = Schema.Struct({
140208 id: TrimmedNonEmptyString,
141209 credential: TrimmedNonEmptyString,
142 role: AuthSessionRole,
210 scopes: AuthEnvironmentScopes,
143211 subject: TrimmedNonEmptyString,
144212 label: Schema.optionalKey(TrimmedNonEmptyString),
145213 createdAt: Schema.DateTimeUtc,
......@@ -147,15 +215,6 @@ export const AuthPairingLink = Schema.Struct({
147215});
148216export type AuthPairingLink = typeof AuthPairingLink.Type;
149217
150export const AuthClientMetadataDeviceType = Schema.Literals([
151 "desktop",
152 "mobile",
153 "tablet",
154 "bot",
155 "unknown",
156]);
157export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type;
158
159218export const AuthClientMetadata = Schema.Struct({
160219 label: Schema.optionalKey(TrimmedNonEmptyString),
161220 ipAddress: Schema.optionalKey(TrimmedNonEmptyString),
......@@ -169,7 +228,7 @@ export type AuthClientMetadata = typeof AuthClientMetadata.Type;
169228export const AuthClientSession = Schema.Struct({
170229 sessionId: AuthSessionId,
171230 subject: TrimmedNonEmptyString,
172 role: AuthSessionRole,
231 scopes: AuthEnvironmentScopes,
173232 method: ServerAuthSessionMethod,
174233 client: AuthClientMetadata,
175234 issuedAt: Schema.DateTimeUtc,
......@@ -214,6 +273,21 @@ export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({
214273export type AuthAccessStreamPairingLinkRemovedEvent =
215274 typeof AuthAccessStreamPairingLinkRemovedEvent.Type;
216275
276export class AuthAccessStreamError extends Schema.TaggedErrorClass<AuthAccessStreamError>()(
277 "AuthAccessStreamError",
278 {
279 message: Schema.String,
280 },
281) {}
282
283export class EnvironmentAuthorizationError extends Schema.TaggedErrorClass<EnvironmentAuthorizationError>()(
284 "EnvironmentAuthorizationError",
285 {
286 message: Schema.String,
287 requiredScope: AuthEnvironmentScope,
288 },
289) {}
290
217291export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({
218292 version: Schema.Literal(1),
219293 revision: Schema.Number,
......@@ -253,13 +327,14 @@ export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.T
253327
254328export const AuthCreatePairingCredentialInput = Schema.Struct({
255329 label: Schema.optionalKey(TrimmedNonEmptyString),
330 scopes: Schema.optionalKey(AuthEnvironmentScopes),
256331});
257332export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
258333
259334export const AuthSessionState = Schema.Struct({
260335 authenticated: Schema.Boolean,
261336 auth: ServerAuthDescriptor,
262 role: Schema.optionalKey(AuthSessionRole),
337 scopes: Schema.optionalKey(AuthEnvironmentScopes),
263338 sessionMethod: Schema.optionalKey(ServerAuthSessionMethod),
264339 expiresAt: Schema.optionalKey(Schema.DateTimeUtc),
265340});
packages/contracts/src/environmentHttp.ts created+325
......@@ -0,0 +1,325 @@
1import * as Context from "effect/Context";
2import type * as DateTime from "effect/DateTime";
3import * as Schema from "effect/Schema";
4import * as HttpApi from "effect/unstable/httpapi/HttpApi";
5import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
6import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
7import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware";
8import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10
11import {
12 AuthAccessTokenResult,
13 AuthBrowserSessionRequest,
14 AuthBrowserSessionResult,
15 AuthClientSession,
16 AuthCreatePairingCredentialInput,
17 AuthPairingCredentialResult,
18 AuthPairingLink,
19 AuthRevokeClientSessionInput,
20 AuthRevokePairingLinkInput,
21 AuthEnvironmentScope,
22 AuthTokenExchangeRequest,
23 AuthSessionState,
24 AuthWebSocketTicketResult,
25 ServerAuthSessionMethod,
26} from "./auth.ts";
27import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";
28import { ExecutionEnvironmentDescriptor } from "./environment.ts";
29import {
30 ClientOrchestrationCommand,
31 DispatchResult,
32 OrchestrationReadModel,
33} from "./orchestration.ts";
34
35const OptionalBearerHeaders = Schema.Struct({
36 authorization: Schema.optionalKey(Schema.String),
37});
38
39export const EnvironmentRequestInvalidReason = Schema.Literals([
40 "invalid_scope",
41 "scope_not_granted",
42 "invalid_command",
43]);
44export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type;
45
46export const EnvironmentAuthInvalidReason = Schema.Literals([
47 "missing_credential",
48 "invalid_credential",
49]);
50export type EnvironmentAuthInvalidReason = typeof EnvironmentAuthInvalidReason.Type;
51
52export const EnvironmentOperationForbiddenReason = Schema.Literals([
53 "current_session_revoke_not_allowed",
54]);
55export type EnvironmentOperationForbiddenReason = typeof EnvironmentOperationForbiddenReason.Type;
56
57export const EnvironmentInternalErrorReason = Schema.Literals([
58 "bootstrap_validation_failed",
59 "browser_session_issuance_failed",
60 "browser_session_cookie_failed",
61 "access_token_issuance_failed",
62 "websocket_ticket_issuance_failed",
63 "pairing_credential_issuance_failed",
64 "pairing_links_load_failed",
65 "pairing_link_revoke_failed",
66 "client_sessions_load_failed",
67 "client_session_revoke_failed",
68 "orchestration_snapshot_failed",
69 "orchestration_dispatch_failed",
70 "internal_error",
71]);
72export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type;
73
74export class EnvironmentRequestInvalidError extends Schema.TaggedErrorClass<EnvironmentRequestInvalidError>()(
75 "EnvironmentRequestInvalidError",
76 {
77 code: Schema.Literal("invalid_request"),
78 reason: EnvironmentRequestInvalidReason,
79 traceId: TrimmedNonEmptyString,
80 },
81 { httpApiStatus: 400 },
82) {
83 [HttpServerRespondable.symbol]() {
84 return HttpServerResponse.schemaJson(EnvironmentRequestInvalidError)(this, { status: 400 });
85 }
86}
87
88export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass<EnvironmentAuthInvalidError>()(
89 "EnvironmentAuthInvalidError",
90 {
91 code: Schema.Literal("auth_invalid"),
92 reason: EnvironmentAuthInvalidReason,
93 traceId: TrimmedNonEmptyString,
94 },
95 { httpApiStatus: 401 },
96) {
97 [HttpServerRespondable.symbol]() {
98 return HttpServerResponse.schemaJson(EnvironmentAuthInvalidError)(this, { status: 401 });
99 }
100}
101
102export class EnvironmentScopeRequiredError extends Schema.TaggedErrorClass<EnvironmentScopeRequiredError>()(
103 "EnvironmentScopeRequiredError",
104 {
105 code: Schema.Literal("insufficient_scope"),
106 requiredScope: AuthEnvironmentScope,
107 traceId: TrimmedNonEmptyString,
108 },
109 { httpApiStatus: 403 },
110) {
111 [HttpServerRespondable.symbol]() {
112 return HttpServerResponse.schemaJson(EnvironmentScopeRequiredError)(this, { status: 403 });
113 }
114}
115
116export class EnvironmentOperationForbiddenError extends Schema.TaggedErrorClass<EnvironmentOperationForbiddenError>()(
117 "EnvironmentOperationForbiddenError",
118 {
119 code: Schema.Literal("operation_forbidden"),
120 reason: EnvironmentOperationForbiddenReason,
121 traceId: TrimmedNonEmptyString,
122 },
123 { httpApiStatus: 403 },
124) {
125 [HttpServerRespondable.symbol]() {
126 return HttpServerResponse.schemaJson(EnvironmentOperationForbiddenError)(this, { status: 403 });
127 }
128}
129
130export class EnvironmentInternalError extends Schema.TaggedErrorClass<EnvironmentInternalError>()(
131 "EnvironmentInternalError",
132 {
133 code: Schema.Literal("internal_error"),
134 reason: EnvironmentInternalErrorReason,
135 traceId: TrimmedNonEmptyString,
136 },
137 { httpApiStatus: 500 },
138) {
139 [HttpServerRespondable.symbol]() {
140 return HttpServerResponse.schemaJson(EnvironmentInternalError)(this, { status: 500 });
141 }
142}
143
144export const EnvironmentHttpCommonError = Schema.Union([
145 EnvironmentRequestInvalidError,
146 EnvironmentAuthInvalidError,
147 EnvironmentScopeRequiredError,
148 EnvironmentOperationForbiddenError,
149 EnvironmentInternalError,
150]);
151export type EnvironmentHttpCommonError = typeof EnvironmentHttpCommonError.Type;
152
153const EnvironmentAuthenticationErrors = [
154 EnvironmentAuthInvalidError,
155 EnvironmentInternalError,
156] as const;
157const EnvironmentSessionCreationErrors = [
158 EnvironmentAuthInvalidError,
159 EnvironmentInternalError,
160] as const;
161const EnvironmentTokenExchangeErrors = [
162 EnvironmentRequestInvalidError,
163 EnvironmentAuthInvalidError,
164 EnvironmentInternalError,
165] as const;
166const EnvironmentScopedOperationErrors = [
167 EnvironmentScopeRequiredError,
168 EnvironmentInternalError,
169] as const;
170const EnvironmentPairingCredentialErrors = [
171 EnvironmentRequestInvalidError,
172 ...EnvironmentScopedOperationErrors,
173] as const;
174const EnvironmentSessionRevokeErrors = [
175 EnvironmentScopeRequiredError,
176 EnvironmentOperationForbiddenError,
177 EnvironmentInternalError,
178] as const;
179const EnvironmentOrchestrationSnapshotErrors = [
180 EnvironmentScopeRequiredError,
181 EnvironmentInternalError,
182] as const;
183const EnvironmentOrchestrationDispatchErrors = [
184 EnvironmentRequestInvalidError,
185 EnvironmentScopeRequiredError,
186 EnvironmentInternalError,
187] as const;
188
189export interface EnvironmentSessionPrincipalShape {
190 readonly sessionId: AuthSessionId;
191 readonly subject: string;
192 readonly method: ServerAuthSessionMethod;
193 readonly scopes: ReadonlySet<AuthEnvironmentScope>;
194 readonly expiresAt?: DateTime.DateTime;
195}
196
197export class EnvironmentAuthenticatedPrincipal extends Context.Service<
198 EnvironmentAuthenticatedPrincipal,
199 EnvironmentSessionPrincipalShape
200>()("@t3tools/contracts/environmentHttp/EnvironmentAuthenticatedPrincipal") {}
201
202export class EnvironmentAuthenticatedAuth extends HttpApiMiddleware.Service<
203 EnvironmentAuthenticatedAuth,
204 { provides: EnvironmentAuthenticatedPrincipal }
205>()("EnvironmentAuthenticatedAuth", {
206 error: EnvironmentAuthenticationErrors,
207}) {}
208
209export const AuthPairingLinkRevokeResult = Schema.Struct({
210 revoked: Schema.Boolean,
211});
212export type AuthPairingLinkRevokeResult = typeof AuthPairingLinkRevokeResult.Type;
213
214export const AuthClientSessionRevokeResult = Schema.Struct({
215 revoked: Schema.Boolean,
216});
217export type AuthClientSessionRevokeResult = typeof AuthClientSessionRevokeResult.Type;
218
219export const AuthOtherClientSessionsRevokeResult = Schema.Struct({
220 revokedCount: Schema.Number,
221});
222export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type;
223
224export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add(
225 HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", {
226 success: ExecutionEnvironmentDescriptor,
227 }),
228) {}
229
230export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
231 .add(
232 HttpApiEndpoint.get("session", "/api/auth/session", {
233 headers: OptionalBearerHeaders,
234 success: AuthSessionState,
235 error: [EnvironmentInternalError],
236 }),
237 )
238 .add(
239 HttpApiEndpoint.post("browserSession", "/api/auth/browser-session", {
240 payload: AuthBrowserSessionRequest,
241 success: AuthBrowserSessionResult,
242 error: EnvironmentSessionCreationErrors,
243 }),
244 )
245 .add(
246 HttpApiEndpoint.post("token", "/oauth/token", {
247 payload: AuthTokenExchangeRequest,
248 success: AuthAccessTokenResult,
249 error: EnvironmentTokenExchangeErrors,
250 }),
251 )
252 .add(
253 HttpApiEndpoint.post("webSocketTicket", "/api/auth/websocket-ticket", {
254 headers: OptionalBearerHeaders,
255 success: AuthWebSocketTicketResult,
256 error: [EnvironmentInternalError],
257 }).middleware(EnvironmentAuthenticatedAuth),
258 )
259 .add(
260 HttpApiEndpoint.post("pairingCredential", "/api/auth/pairing-token", {
261 headers: OptionalBearerHeaders,
262 payload: AuthCreatePairingCredentialInput,
263 success: AuthPairingCredentialResult,
264 error: EnvironmentPairingCredentialErrors,
265 }).middleware(EnvironmentAuthenticatedAuth),
266 )
267 .add(
268 HttpApiEndpoint.get("pairingLinks", "/api/auth/pairing-links", {
269 headers: OptionalBearerHeaders,
270 success: Schema.Array(AuthPairingLink),
271 error: EnvironmentScopedOperationErrors,
272 }).middleware(EnvironmentAuthenticatedAuth),
273 )
274 .add(
275 HttpApiEndpoint.post("revokePairingLink", "/api/auth/pairing-links/revoke", {
276 headers: OptionalBearerHeaders,
277 payload: AuthRevokePairingLinkInput,
278 success: AuthPairingLinkRevokeResult,
279 error: EnvironmentScopedOperationErrors,
280 }).middleware(EnvironmentAuthenticatedAuth),
281 )
282 .add(
283 HttpApiEndpoint.get("clients", "/api/auth/clients", {
284 headers: OptionalBearerHeaders,
285 success: Schema.Array(AuthClientSession),
286 error: EnvironmentScopedOperationErrors,
287 }).middleware(EnvironmentAuthenticatedAuth),
288 )
289 .add(
290 HttpApiEndpoint.post("revokeClient", "/api/auth/clients/revoke", {
291 headers: OptionalBearerHeaders,
292 payload: AuthRevokeClientSessionInput,
293 success: AuthClientSessionRevokeResult,
294 error: EnvironmentSessionRevokeErrors,
295 }).middleware(EnvironmentAuthenticatedAuth),
296 )
297 .add(
298 HttpApiEndpoint.post("revokeOtherClients", "/api/auth/clients/revoke-others", {
299 headers: OptionalBearerHeaders,
300 success: AuthOtherClientSessionsRevokeResult,
301 error: EnvironmentScopedOperationErrors,
302 }).middleware(EnvironmentAuthenticatedAuth),
303 ) {}
304
305export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
306 .add(
307 HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", {
308 headers: OptionalBearerHeaders,
309 success: OrchestrationReadModel,
310 error: EnvironmentOrchestrationSnapshotErrors,
311 }).middleware(EnvironmentAuthenticatedAuth),
312 )
313 .add(
314 HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", {
315 headers: OptionalBearerHeaders,
316 payload: ClientOrchestrationCommand,
317 success: DispatchResult,
318 error: EnvironmentOrchestrationDispatchErrors,
319 }).middleware(EnvironmentAuthenticatedAuth),
320 ) {}
321
322export class EnvironmentHttpApi extends HttpApi.make("environment")
323 .add(EnvironmentMetadataHttpApi)
324 .add(EnvironmentAuthHttpApi)
325 .add(EnvironmentOrchestrationHttpApi) {}
packages/contracts/src/index.ts+1
......@@ -1,6 +1,7 @@
11export * from "./baseSchemas.ts";
22export * from "./auth.ts";
33export * from "./environment.ts";
4export * from "./environmentHttp.ts";
45export * from "./desktopBootstrap.ts";
56export * from "./remoteAccess.ts";
67export * from "./ipc.ts";
packages/contracts/src/ipc.ts+4-4
......@@ -66,7 +66,7 @@ import type {
6666 OrchestrationThreadStreamItem,
6767} from "./orchestration.ts";
6868import { EnvironmentId } from "./baseSchemas.ts";
69import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts";
69import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
7070import { AdvertisedEndpoint } from "./remoteAccess.ts";
7171import { EditorId } from "./editor.ts";
7272import { ExecutionEnvironmentDescriptor } from "./environment.ts";
......@@ -394,12 +394,12 @@ export interface DesktopBridge {
394394 bootstrapSshBearerSession: (
395395 httpBaseUrl: string,
396396 credential: string,
397 ) => Promise<AuthBearerBootstrapResult>;
397 ) => Promise<AuthAccessTokenResult>;
398398 fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise<AuthSessionState>;
399 issueSshWebSocketToken: (
399 issueSshWebSocketTicket: (
400400 httpBaseUrl: string,
401401 bearerToken: string,
402 ) => Promise<AuthWebSocketTokenResult>;
402 ) => Promise<AuthWebSocketTicketResult>;
403403 onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void;
404404 resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise<void>;
405405 getServerExposureState: () => Promise<DesktopServerExposureState>;
packages/contracts/src/rpc.ts+56-42
......@@ -3,7 +3,11 @@ import * as Rpc from "effect/unstable/rpc/Rpc";
33import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44
55import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";
6import { AuthAccessStreamEvent } from "./auth.ts";
6import {
7 AuthAccessStreamError,
8 AuthAccessStreamEvent,
9 EnvironmentAuthorizationError,
10} from "./auth.ts";
711import {
812 FilesystemBrowseInput,
913 FilesystemBrowseResult,
......@@ -179,19 +183,19 @@ export const WS_METHODS = {
179183export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
180184 payload: ServerUpsertKeybindingInput,
181185 success: ServerUpsertKeybindingResult,
182 error: KeybindingsConfigError,
186 error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
183187});
184188
185189export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, {
186190 payload: ServerRemoveKeybindingInput,
187191 success: ServerRemoveKeybindingResult,
188 error: KeybindingsConfigError,
192 error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]),
189193});
190194
191195export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, {
192196 payload: Schema.Struct({}),
193197 success: ServerConfig,
194 error: Schema.Union([KeybindingsConfigError, ServerSettingsError]),
198 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
195199});
196200
197201export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, {
......@@ -205,39 +209,43 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv
205209 instanceId: Schema.optional(ProviderInstanceId),
206210 }),
207211 success: ServerProviderUpdatedPayload,
212 error: EnvironmentAuthorizationError,
208213});
209214
210215export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {
211216 payload: ServerProviderUpdateInput,
212217 success: ServerProviderUpdatedPayload,
213 error: ServerProviderUpdateError,
218 error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]),
214219});
215220
216221export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
217222 payload: Schema.Struct({}),
218223 success: ServerSettings,
219 error: ServerSettingsError,
224 error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]),
220225});
221226
222227export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, {
223228 payload: Schema.Struct({ patch: ServerSettingsPatch }),
224229 success: ServerSettings,
225 error: ServerSettingsError,
230 error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]),
226231});
227232
228233export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, {
229234 payload: Schema.Struct({}),
230235 success: SourceControlDiscoveryResult,
236 error: EnvironmentAuthorizationError,
231237});
232238
233239export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, {
234240 payload: Schema.Struct({}),
235241 success: ServerTraceDiagnosticsResult,
242 error: EnvironmentAuthorizationError,
236243});
237244
238245export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, {
239246 payload: Schema.Struct({}),
240247 success: ServerProcessDiagnosticsResult,
248 error: EnvironmentAuthorizationError,
241249});
242250
243251export const WsServerGetProcessResourceHistoryRpc = Rpc.make(
......@@ -245,12 +253,14 @@ export const WsServerGetProcessResourceHistoryRpc = Rpc.make(
245253 {
246254 payload: ServerProcessResourceHistoryInput,
247255 success: ServerProcessResourceHistoryResult,
256 error: EnvironmentAuthorizationError,
248257 },
249258);
250259
251260export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
252261 payload: ServerSignalProcessInput,
253262 success: ServerSignalProcessResult,
263 error: EnvironmentAuthorizationError,
254264});
255265
256266export const WsSourceControlLookupRepositoryRpc = Rpc.make(
......@@ -258,14 +268,14 @@ export const WsSourceControlLookupRepositoryRpc = Rpc.make(
258268 {
259269 payload: SourceControlRepositoryLookupInput,
260270 success: SourceControlRepositoryInfo,
261 error: SourceControlRepositoryError,
271 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
262272 },
263273);
264274
265275export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, {
266276 payload: SourceControlCloneRepositoryInput,
267277 success: SourceControlCloneRepositoryResult,
268 error: SourceControlRepositoryError,
278 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
269279});
270280
271281export const WsSourceControlPublishRepositoryRpc = Rpc.make(
......@@ -273,103 +283,103 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make(
273283 {
274284 payload: SourceControlPublishRepositoryInput,
275285 success: SourceControlPublishRepositoryResult,
276 error: SourceControlRepositoryError,
286 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
277287 },
278288);
279289
280290export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
281291 payload: ProjectSearchEntriesInput,
282292 success: ProjectSearchEntriesResult,
283 error: ProjectSearchEntriesError,
293 error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]),
284294});
285295
286296export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
287297 payload: ProjectWriteFileInput,
288298 success: ProjectWriteFileResult,
289 error: ProjectWriteFileError,
299 error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]),
290300});
291301
292302export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
293303 payload: LaunchEditorInput,
294 error: ExternalLauncherError,
304 error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]),
295305});
296306
297307export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
298308 payload: FilesystemBrowseInput,
299309 success: FilesystemBrowseResult,
300 error: FilesystemBrowseError,
310 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),
301311});
302312
303313export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
304314 payload: VcsStatusInput,
305315 success: VcsStatusStreamEvent,
306 error: GitManagerServiceError,
316 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
307317 stream: true,
308318});
309319
310320export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, {
311321 payload: VcsPullInput,
312322 success: VcsPullResult,
313 error: GitCommandError,
323 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
314324});
315325
316326export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, {
317327 payload: VcsStatusInput,
318328 success: VcsStatusResult,
319 error: GitManagerServiceError,
329 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
320330});
321331
322332export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
323333 payload: GitRunStackedActionInput,
324334 success: GitActionProgressEvent,
325 error: GitManagerServiceError,
335 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
326336 stream: true,
327337});
328338
329339export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, {
330340 payload: GitPullRequestRefInput,
331341 success: GitResolvePullRequestResult,
332 error: GitManagerServiceError,
342 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
333343});
334344
335345export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, {
336346 payload: GitPreparePullRequestThreadInput,
337347 success: GitPreparePullRequestThreadResult,
338 error: GitManagerServiceError,
348 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
339349});
340350
341351export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, {
342352 payload: VcsListRefsInput,
343353 success: VcsListRefsResult,
344 error: GitCommandError,
354 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
345355});
346356
347357export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, {
348358 payload: VcsCreateWorktreeInput,
349359 success: VcsCreateWorktreeResult,
350 error: GitCommandError,
360 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
351361});
352362
353363export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, {
354364 payload: VcsRemoveWorktreeInput,
355 error: GitCommandError,
365 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
356366});
357367
358368export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, {
359369 payload: VcsCreateRefInput,
360370 success: VcsCreateRefResult,
361 error: GitCommandError,
371 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
362372});
363373
364374export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, {
365375 payload: VcsSwitchRefInput,
366376 success: VcsSwitchRefResult,
367 error: GitCommandError,
377 error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]),
368378});
369379
370380export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {
371381 payload: VcsInitInput,
372 error: VcsError,
382 error: Schema.Union([VcsError, EnvironmentAuthorizationError]),
373383});
374384
375385/**
......@@ -380,46 +390,46 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {
380390export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, {
381391 payload: ReviewDiffPreviewInput,
382392 success: ReviewDiffPreviewResult,
383 error: ReviewDiffPreviewError,
393 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
384394});
385395
386396export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
387397 payload: TerminalOpenInput,
388398 success: TerminalSessionSnapshot,
389 error: TerminalError,
399 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
390400});
391401
392402export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, {
393403 payload: TerminalAttachInput,
394404 success: TerminalAttachStreamEvent,
395 error: TerminalError,
405 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
396406 stream: true,
397407});
398408
399409export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, {
400410 payload: TerminalWriteInput,
401 error: TerminalError,
411 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
402412});
403413
404414export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, {
405415 payload: TerminalResizeInput,
406 error: TerminalError,
416 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
407417});
408418
409419export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, {
410420 payload: TerminalClearInput,
411 error: TerminalError,
421 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
412422});
413423
414424export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, {
415425 payload: TerminalRestartInput,
416426 success: TerminalSessionSnapshot,
417 error: TerminalError,
427 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
418428});
419429
420430export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, {
421431 payload: TerminalCloseInput,
422 error: TerminalError,
432 error: Schema.Union([TerminalError, EnvironmentAuthorizationError]),
423433});
424434
425435export const WsOrchestrationDispatchCommandRpc = Rpc.make(
......@@ -427,14 +437,14 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make(
427437 {
428438 payload: ClientOrchestrationCommand,
429439 success: OrchestrationRpcSchemas.dispatchCommand.output,
430 error: OrchestrationDispatchCommandError,
440 error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]),
431441 },
432442);
433443
434444export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {
435445 payload: OrchestrationGetTurnDiffInput,
436446 success: OrchestrationRpcSchemas.getTurnDiff.output,
437 error: OrchestrationGetTurnDiffError,
447 error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]),
438448});
439449
440450export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
......@@ -442,14 +452,14 @@ export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(
442452 {
443453 payload: OrchestrationGetFullThreadDiffInput,
444454 success: OrchestrationRpcSchemas.getFullThreadDiff.output,
445 error: OrchestrationGetFullThreadDiffError,
455 error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]),
446456 },
447457);
448458
449459export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.replayEvents, {
450460 payload: OrchestrationReplayEventsInput,
451461 success: OrchestrationRpcSchemas.replayEvents.output,
452 error: OrchestrationReplayEventsError,
462 error: Schema.Union([OrchestrationReplayEventsError, EnvironmentAuthorizationError]),
453463});
454464
455465export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(
......@@ -457,14 +467,14 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(
457467 {
458468 payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input,
459469 success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output,
460 error: OrchestrationGetSnapshotError,
470 error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]),
461471 },
462472);
463473
464474export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, {
465475 payload: OrchestrationRpcSchemas.subscribeShell.input,
466476 success: OrchestrationRpcSchemas.subscribeShell.output,
467 error: OrchestrationGetSnapshotError,
477 error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]),
468478 stream: true,
469479});
470480
......@@ -473,7 +483,7 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make(
473483 {
474484 payload: OrchestrationRpcSchemas.subscribeThread.input,
475485 success: OrchestrationRpcSchemas.subscribeThread.output,
476 error: OrchestrationGetSnapshotError,
486 error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]),
477487 stream: true,
478488 },
479489);
......@@ -481,31 +491,35 @@ export const WsOrchestrationSubscribeThreadRpc = Rpc.make(
481491export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, {
482492 payload: Schema.Struct({}),
483493 success: TerminalEvent,
494 error: EnvironmentAuthorizationError,
484495 stream: true,
485496});
486497
487498export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, {
488499 payload: Schema.Struct({}),
489500 success: TerminalMetadataStreamEvent,
501 error: EnvironmentAuthorizationError,
490502 stream: true,
491503});
492504
493505export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, {
494506 payload: Schema.Struct({}),
495507 success: ServerConfigStreamEvent,
496 error: Schema.Union([KeybindingsConfigError, ServerSettingsError]),
508 error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]),
497509 stream: true,
498510});
499511
500512export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, {
501513 payload: Schema.Struct({}),
502514 success: ServerLifecycleStreamEvent,
515 error: EnvironmentAuthorizationError,
503516 stream: true,
504517});
505518
506519export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, {
507520 payload: Schema.Struct({}),
508521 success: AuthAccessStreamEvent,
522 error: Schema.Union([AuthAccessStreamError, EnvironmentAuthorizationError]),
509523 stream: true,
510524});
511525
packages/shared/package.json+4
......@@ -79,6 +79,10 @@
7979 "types": "./src/remote.ts",
8080 "import": "./src/remote.ts"
8181 },
82 "./oauthScope": {
83 "types": "./src/oauthScope.ts",
84 "import": "./src/oauthScope.ts"
85 },
8286 "./searchRanking": {
8387 "types": "./src/searchRanking.ts",
8488 "import": "./src/searchRanking.ts"
packages/shared/src/oauthScope.test.ts created+35
......@@ -0,0 +1,35 @@
1import { describe, expect, it } from "vitest";
2
3import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts";
4
5describe("OAuth scopes", () => {
6 it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => {
7 expect(parseOAuthScope("orchestration:read access:write orchestration:read")).toEqual([
8 "orchestration:read",
9 "access:write",
10 ]);
11 });
12
13 it("rejects whitespace that is not the SP delimiter or introduces empty tokens", () => {
14 expect(parseOAuthScope("orchestration:read\taccess:write")).toBeNull();
15 expect(parseOAuthScope("orchestration:read access:write")).toBeNull();
16 });
17
18 it("encodes and restricts requested scopes to the allowed capability set", () => {
19 expect(encodeOAuthScope(["orchestration:read", "access:write"])).toBe(
20 "orchestration:read access:write",
21 );
22 expect(
23 parseAllowedOAuthScope({
24 value: "orchestration:read access:write",
25 allowedScopes: new Set(["orchestration:read", "access:write"] as const),
26 }),
27 ).toEqual(["orchestration:read", "access:write"]);
28 expect(
29 parseAllowedOAuthScope({
30 value: "orchestration:read relay:write",
31 allowedScopes: new Set(["orchestration:read", "access:write"] as const),
32 }),
33 ).toBeNull();
34 });
35});
packages/shared/src/oauthScope.ts created+40
......@@ -0,0 +1,40 @@
1const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;
2
3/**
4 * Decodes an RFC 6749 `scope` value as a set while preserving first-seen order.
5 */
6export function parseOAuthScope(value: string): ReadonlyArray<string> | null {
7 if (value.length === 0) {
8 return null;
9 }
10
11 const scopes = value.split(" ");
12 if (scopes.some((scope) => !OAUTH_SCOPE_TOKEN.test(scope))) {
13 return null;
14 }
15
16 return [...new Set(scopes)];
17}
18
19export function encodeOAuthScope(scopes: ReadonlyArray<string>): string {
20 const encoded = scopes.join(" ");
21 const parsed = parseOAuthScope(encoded);
22 if (parsed === null || parsed.length !== scopes.length) {
23 throw new Error("OAuth scopes must be non-empty, valid, and unique.");
24 }
25 return encoded;
26}
27
28export function parseAllowedOAuthScope<Scope extends string>(input: {
29 readonly value: string;
30 readonly allowedScopes: ReadonlySet<Scope>;
31}): ReadonlyArray<Scope> | null {
32 const scopes = parseOAuthScope(input.value);
33 if (
34 scopes === null ||
35 !scopes.every((scope): scope is Scope => input.allowedScopes.has(scope as Scope))
36 ) {
37 return null;
38 }
39 return scopes;
40}
packages/shared/src/observability.test.ts+16
......@@ -1,6 +1,7 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as Arr from "effect/Array";
4import * as Cause from "effect/Cause";
45import * as Effect from "effect/Effect";
56import * as FileSystem from "effect/FileSystem";
67import * as Layer from "effect/Layer";
......@@ -12,12 +13,27 @@ import * as Schema from "effect/Schema";
1213import * as Tracer from "effect/Tracer";
1314
1415import {
16 causeErrorTag,
1517 compactTraceAttributes,
1618 makeLocalFileTracer,
1719 makeTraceSink,
1820 type TraceRecord,
1921} from "./observability.ts";
2022
23describe("causeErrorTag", () => {
24 it("reports the tagged failure value instead of the Cause reason wrapper", () => {
25 assert.equal(
26 causeErrorTag(Cause.fail({ _tag: "ServerAuthInvalidCredentialError" })),
27 "ServerAuthInvalidCredentialError",
28 );
29 });
30
31 it("reports structural cause kinds when no typed failure exists", () => {
32 assert.equal(causeErrorTag(Cause.die(new Error("unexpected"))), "Die");
33 assert.equal(causeErrorTag(Cause.interrupt()), "Interrupt");
34 });
35});
36
2137const TraceRecordLine = Schema.Struct({
2238 name: Schema.String,
2339 spanId: Schema.String,
packages/shared/src/observability.ts+16
......@@ -73,6 +73,22 @@ export interface OtlpTraceRecord extends BaseTraceRecord {
7373
7474export type TraceRecord = EffectTraceRecord | OtlpTraceRecord;
7575
76function taggedErrorName(error: unknown): string {
77 return typeof error === "object" && error !== null && "_tag" in error
78 ? String(error._tag)
79 : error instanceof Error
80 ? error.name
81 : typeof error;
82}
83
84export function causeErrorTag(cause: Cause.Cause<unknown>): string {
85 const failure = Cause.findErrorOption(cause);
86 if (Option.isSome(failure)) {
87 return taggedErrorName(failure.value);
88 }
89 return cause.reasons[0]?._tag ?? "Empty";
90}
91
7692export interface TraceSinkOptions {
7793 readonly filePath: string;
7894 readonly maxBytes: number;
packages/ssh/src/tunnel.test.ts+2-2
......@@ -293,7 +293,7 @@ describe("ssh tunnel scripts", () => {
293293 makeSuccessfulProcess(`{
294294 "id": "88941235-6ed5-4184-a2ff-5339e2075958",
295295 "credential": "LCL4R2TPHDKQ",
296 "role": "client",
296 "scopes": ["orchestration:read"],
297297 "expiresAt": "2026-04-29T01:01:20.994Z"
298298}
299299
......@@ -321,7 +321,7 @@ describe("ssh tunnel scripts", () => {
321321{
322322 "id": "88941235-6ed5-4184-a2ff-5339e2075958",
323323 "credential": "LCL4R2TPHDKQ",
324 "role": "client",
324 "scopes": ["orchestration:read"],
325325 "expiresAt": "2026-04-29T01:01:20.994Z"
326326}
327327
packages/ssh/src/tunnel.ts+17-87
......@@ -160,13 +160,8 @@ const RemotePairingResult = Schema.Struct({
160160 credential: Schema.String,
161161});
162162
163const RemoteHttpError = Schema.Struct({
164 error: Schema.optional(Schema.String),
165});
166
167163const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult));
168164const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));
169const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError));
170165
171166const decodeRemoteJsonOutput = <A, E>(
172167 stdout: string,
......@@ -991,92 +986,27 @@ function isLoopbackHostname(hostname: string): boolean {
991986 return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
992987}
993988
994function resolveLoopbackSshHttpUrl(
995 rawHttpBaseUrl: unknown,
996 pathname: string,
997): Effect.Effect<URL, SshHttpBridgeError> {
998 return Effect.try({
999 try: () => {
1000 if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) {
1001 throw new Error("Invalid SSH forwarded http base URL.");
1002 }
1003 const baseUrl = new URL(rawHttpBaseUrl);
1004 if (!isLoopbackHostname(baseUrl.hostname)) {
1005 throw new Error("SSH desktop bridge only supports loopback forwarded URLs.");
1006 }
1007 const url = new URL(baseUrl.toString());
1008 url.pathname = pathname;
1009 url.search = "";
1010 url.hash = "";
1011 return url;
1012 },
1013 catch: (cause) =>
1014 new SshHttpBridgeError({
1015 message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.",
1016 cause,
1017 }),
1018 });
1019}
1020
1021export const fetchLoopbackSshJson = Effect.fn("ssh/tunnel.fetchLoopbackSshJson")(function* <
1022 T,
1023>(input: {
1024 readonly httpBaseUrl: unknown;
1025 readonly pathname: string;
1026 readonly method?: "GET" | "POST";
1027 readonly bearerToken?: unknown;
1028 readonly body?: unknown;
1029}): Effect.fn.Return<T, SshHttpBridgeError, HttpClient.HttpClient> {
1030 const requestUrl = yield* resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname);
1031 const bearerToken =
1032 typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0
1033 ? input.bearerToken
1034 : null;
1035
1036 const request = (
1037 input.method === "POST"
1038 ? HttpClientRequest.post(requestUrl.toString())
1039 : HttpClientRequest.get(requestUrl.toString())
1040 ).pipe(
1041 input.body === undefined ? (req) => req : HttpClientRequest.bodyJsonUnsafe(input.body),
1042 bearerToken
1043 ? HttpClientRequest.setHeader("authorization", `Bearer ${bearerToken}`)
1044 : (req) => req,
1045 );
1046 const client = yield* HttpClient.HttpClient;
1047 const response = yield* client.execute(request).pipe(
1048 Effect.mapError(
1049 (cause) =>
989export const resolveLoopbackSshHttpBaseUrl = Effect.fn("ssh/tunnel.resolveLoopbackSshHttpBaseUrl")(
990 function* (rawHttpBaseUrl: unknown): Effect.fn.Return<string, SshHttpBridgeError> {
991 return yield* Effect.try({
992 try: () => {
993 if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) {
994 throw new Error("Invalid SSH forwarded http base URL.");
995 }
996 const baseUrl = new URL(rawHttpBaseUrl);
997 if (!isLoopbackHostname(baseUrl.hostname)) {
998 throw new Error("SSH desktop bridge only supports loopback forwarded URLs.");
999 }
1000 return baseUrl.toString();
1001 },
1002 catch: (cause) =>
10501003 new SshHttpBridgeError({
1051 message: `Failed to reach SSH forwarded endpoint ${requestUrl.toString()}.`,
1004 message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.",
10521005 cause,
10531006 }),
1054 ),
1055 );
1056 if (response.status < 200 || response.status >= 300) {
1057 const text = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")));
1058 const parsedError = yield* decodeRemoteHttpError(text).pipe(
1059 Effect.catch(() => Effect.succeed(null)),
1060 );
1061 const message =
1062 parsedError?.error && parsedError.error.trim().length > 0
1063 ? parsedError.error
1064 : text || `SSH forwarded request failed (${response.status}).`;
1065 return yield* new SshHttpBridgeError({
1066 status: response.status,
1067 message: `[ssh_http:${response.status}] ${message} (${input.method ?? "GET"} ${requestUrl.toString()})`,
10681007 });
1069 }
1070 return (yield* response.json.pipe(
1071 Effect.mapError(
1072 (cause) =>
1073 new SshHttpBridgeError({
1074 message: `SSH forwarded endpoint ${requestUrl.toString()} returned invalid JSON.`,
1075 cause,
1076 }),
1077 ),
1078 )) as T;
1079});
1008 },
1009);
10801010
10811011const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () {
10821012 const net = yield* NetService.NetService;