Open History to see the last commit
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "@effect/vitest";
4import {
5 AuthAccessTokenType,
6 AuthEnvironmentBootstrapTokenType,
7 AuthTokenExchangeGrantType,
8 EnvironmentHttpApi,
9 EnvironmentId,
10} from "@t3tools/contracts";
11import {
12 RelayEnvironmentLinkProofPayload,
13 type RelayLinkProofRequest,
14} from "@t3tools/contracts/relay";
15import * as Effect from "effect/Effect";
16import * as TestClock from "effect/testing/TestClock";
17import * as Layer from "effect/Layer";
18import * as Schema from "effect/Schema";
19import { HttpClient, HttpServer } from "effect/http";
20import * as Etag from "effect/http/Etag";
21import * as HttpPlatform from "effect/http/HttpPlatform";
22import * as HttpRouter from "effect/http/HttpRouter";
23import * as HttpApi from "effect/http-api/HttpApi";
24import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
25import * as NetAddress from "effect/net/NetAddress";
26import { decodeRelayJwt, RELAY_LINK_PROOF_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
27
28import * as EnvironmentKeys from "./environmentKeys.ts";
29import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
30import * as AuthHttp from "../auth/http.ts";
31import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
32import * as ServerConfig from "../config.ts";
33import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
34import * as SqlitePersistence from "../persistence/Sqlite.ts";
35import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
36import * as CliTokenManager from "./CliTokenManager.ts";
37import * as CloudLink from "./CloudLink.ts";
38import * as ConnectHttp from "./http.ts";
39import * as ConnectReachability from "./ConnectReachability.ts";
40import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
41import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
42
43const DESKTOP_BOOTSTRAP_TOKEN = "test-desktop-bootstrap-token";
44const NON_LOOPBACK_ORIGIN = "http://172.24.162.233:3773";
45const LOOPBACK_ORIGIN = "http://127.0.0.1:3773";
46const proofRequest: RelayLinkProofRequest = {
47 challenge: "test-challenge",
48 relayIssuer: "https://relay.example.test",
49 endpoint: {
50 providerKind: "cloudflare_tunnel",
51 httpBaseUrl: "https://environment.example.test",
52 wsBaseUrl: "wss://environment.example.test",
53 },
54 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
55};
56const decodeLinkProofPayload = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload);
57
58class LinkProofTestApi extends HttpApi.make("environment").add(
59 EnvironmentHttpApi.groups.auth,
60 EnvironmentHttpApi.groups.connect,
61) {}
62
63const layerConfig = Layer.effect(
64 ServerConfig.ServerConfig,
65 Effect.gen(function* () {
66 const config = yield* ServerConfig.ServerConfig;
67 return {
68 ...config,
69 mode: "desktop" as const,
70 host: "0.0.0.0",
71 port: 3773,
72 desktopBootstrapToken: DESKTOP_BOOTSTRAP_TOKEN,
73 };
74 }),
75).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-link-proof-test-" })));
76
77// Real credential issuance, verification, origin checks and signing; no relay or tunnel is started.
78const layerLinkProofRoutes = HttpApiBuilder.layer(LinkProofTestApi).pipe(
79 Layer.provide([AuthHttp.layer, ConnectHttp.layer]),
80 Layer.provide(AuthHttp.layerAuthenticatedAuth),
81 Layer.provide(CloudLink.layer),
82 Layer.provide(EnvironmentKeys.layer),
83 Layer.provide(
84 Layer.mock(ConnectReachability.ConnectReachability)({ latest: Effect.succeedNone }),
85 ),
86 Layer.provide(EnvironmentAuth.layer),
87 Layer.provide([
88 ManagedPolicy.layerTest(),
89 SqlitePersistence.layerMemory,
90 ServerEnvironment.layerIdentity,
91 Layer.mock(ServerEnvironment.ServerEnvironment)({
92 getDescriptor: Effect.succeed({
93 environmentId: EnvironmentId.make("wsl-environment"),
94 label: "WSL",
95 platform: { os: "linux", arch: "x64" },
96 serverVersion: "0.0.0-test",
97 capabilities: { repositoryIdentity: false },
98 }),
99 }),
100 Layer.mock(ManagedEndpointRuntime.CloudManagedEndpointRuntime)({}),
101 Layer.mock(CliTokenManager.CloudCliTokenManager)({}),
102 Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({}),
103 Layer.mock(HttpServer.HttpServer)({
104 address: NetAddress.inetAddressFromIpStringUnsafe("0.0.0.0", 3773),
105 }),
106 Layer.succeed(
107 HttpClient.HttpClient,
108 HttpClient.make(() => Effect.die("Unexpected relay call")),
109 ),
110 ]),
111 Layer.provideMerge(ServerSecretStore.layer),
112 Layer.provide(layerConfig),
113 Layer.provideMerge(HttpPlatform.layer.pipe(Layer.provideMerge(Etag.layerWeak))),
114 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
115);
116
117const responseJson = (response: Response) => Effect.promise(() => response.json());
118
119// Scoped: the web handler and its layer are disposed when the test's scope closes.
120const linkProofClient = Effect.gen(function* () {
121 const { handler } = yield* Effect.acquireRelease(
122 Effect.sync(() => HttpRouter.toWebHandler(layerLinkProofRoutes, { disableLogger: true })),
123 (web) => Effect.promise(() => web.dispose()),
124 );
125 const post = (
126 path: string,
127 payload: unknown,
128 headers?: Record<string, string>,
129 origin = NON_LOOPBACK_ORIGIN,
130 ) =>
131 Effect.promise(() =>
132 handler(
133 new Request(`${origin}${path}`, {
134 method: "POST",
135 headers: { "content-type": "application/json", ...headers },
136 body: JSON.stringify(payload),
137 }),
138 ),
139 );
140 const exchange = Effect.fnUntraced(function* (credential: string, scope?: string) {
141 const response = yield* Effect.promise(() =>
142 handler(
143 new Request(`${NON_LOOPBACK_ORIGIN}/oauth/token`, {
144 method: "POST",
145 body: new URLSearchParams({
146 grant_type: AuthTokenExchangeGrantType,
147 subject_token: credential,
148 subject_token_type: AuthEnvironmentBootstrapTokenType,
149 requested_token_type: AuthAccessTokenType,
150 ...(scope ? { scope } : {}),
151 }),
152 }),
153 ),
154 );
155 expect(response.status).toBe(200);
156 const payload = (yield* responseJson(response)) as { access_token: string };
157 return payload.access_token;
158 });
159 const linkableHere = (token: string, origin = NON_LOOPBACK_ORIGIN) =>
160 Effect.promise(() =>
161 handler(
162 new Request(`${origin}/api/connect/link-state`, {
163 headers: { authorization: `Bearer ${token}` },
164 }),
165 ),
166 ).pipe(
167 Effect.flatMap(responseJson),
168 Effect.map((state) => (state as { linkableHere?: boolean }).linkableHere),
169 );
170 return { post, exchange, linkableHere };
171});
172
173describe("link proof over a non-loopback desktop transport", () => {
174 it.effect(
175 "signs a loopback tunnel origin after desktop bootstrap token exchange over a non-loopback host",
176 () =>
177 Effect.gen(function* () {
178 const { post, exchange } = yield* linkProofClient;
179 const token = yield* exchange(DESKTOP_BOOTSTRAP_TOKEN);
180 const response = yield* post("/api/connect/link-proof", proofRequest, {
181 authorization: `Bearer ${token}`,
182 });
183 expect(response.status).toBe(200);
184 expect(response.headers.get("cache-control")).toBe("no-store");
185 const proof = (yield* responseJson(response)) as string;
186 const unverified = yield* decodeRelayJwt(proof).pipe(
187 Effect.flatMap(decodeLinkProofPayload),
188 );
189 yield* TestClock.setTime(unverified.iat * 1_000);
190 const verified = yield* verifyRelayJwt(RelayEnvironmentLinkProofPayload, {
191 publicKey: unverified.environmentPublicKey,
192 token: proof,
193 typ: RELAY_LINK_PROOF_TYP,
194 issuer: "t3-env:wsl-environment",
195 audience: proofRequest.relayIssuer,
196 });
197 expect(verified).toMatchObject({
198 origin: proofRequest.origin,
199 endpoint: proofRequest.endpoint,
200 challenge: proofRequest.challenge,
201 });
202 }).pipe(Effect.provide(NodeCrypto.layerSignature)),
203 );
204
205 it.effect(
206 "does not pass desktop trust to a paired client or accept client-supplied provenance",
207 () =>
208 Effect.gen(function* () {
209 const { post, exchange, linkableHere } = yield* linkProofClient;
210 const desktop = yield* exchange(DESKTOP_BOOTSTRAP_TOKEN);
211 const paired = yield* post(
212 "/api/auth/pairing-token",
213 {
214 scopes: ["relay:read", "relay:write"],
215 label: "desktop-bootstrap",
216 subject: "desktop-bootstrap",
217 },
218 { authorization: `Bearer ${desktop}` },
219 );
220 expect(paired.status).toBe(200);
221 const { credential } = (yield* responseJson(paired)) as { credential: string };
222 const token = yield* exchange(credential);
223 const headers = { authorization: `Bearer ${token}`, "x-desktop-bootstrap": "true" };
224 const payload = {
225 ...proofRequest,
226 allowNonLoopbackRequestHost: true,
227 subject: "desktop-bootstrap",
228 };
229 const rejected = yield* post("/api/connect/link-proof", payload, headers);
230 expect(rejected.status).toBe(400);
231 expect(yield* responseJson(rejected)).toMatchObject({
232 message: "Invalid managed endpoint origin.",
233 });
234 const loopback = yield* post(
235 "/api/connect/link-proof",
236 proofRequest,
237 headers,
238 LOOPBACK_ORIGIN,
239 );
240 expect(loopback.status).toBe(200);
241 // Clients offer Turn on only to the desktop's own session; SSH forwards look like loopback.
242 expect(yield* linkableHere(desktop)).toBe(true);
243 expect(yield* linkableHere(token)).toBe(false);
244 expect(yield* linkableHere(token, LOOPBACK_ORIGIN)).toBe(false);
245 }),
246 );
247
248 it.effect.each([
249 {
250 name: "non-loopback tunnel origin",
251 origin: { localHttpHost: "172.24.162.233", localHttpPort: 3773 },
252 headers: {},
253 },
254 {
255 name: "mismatched port",
256 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3774 },
257 headers: {},
258 },
259 {
260 name: "forwarded host",
261 origin: proofRequest.origin,
262 headers: { "x-forwarded-host": "127.0.0.1:3773" },
263 },
264 {
265 name: "forwarded protocol",
266 origin: proofRequest.origin,
267 headers: { "x-forwarded-proto": "http" },
268 },
269 ])("rejects $name even for desktop bootstrap", ({ origin, headers }) =>
270 Effect.gen(function* () {
271 const { post, exchange } = yield* linkProofClient;
272 const token = yield* exchange(DESKTOP_BOOTSTRAP_TOKEN);
273 const response = yield* post(
274 "/api/connect/link-proof",
275 { ...proofRequest, origin },
276 { ...headers, authorization: `Bearer ${token}` },
277 );
278 expect(response.status).toBe(400);
279 expect(yield* responseJson(response)).toMatchObject({
280 message: "Invalid managed endpoint origin.",
281 });
282 }),
283 );
284
285 it.effect("requires relay-write scope even for desktop bootstrap", () =>
286 Effect.gen(function* () {
287 const { post, exchange } = yield* linkProofClient;
288 const token = yield* exchange(DESKTOP_BOOTSTRAP_TOKEN, "relay:read");
289 const response = yield* post("/api/connect/link-proof", proofRequest, {
290 authorization: `Bearer ${token}`,
291 });
292 expect(response.status).toBe(403);
293 expect(yield* responseJson(response)).toMatchObject({
294 code: "insufficient_scope",
295 requiredScope: "relay:write",
296 });
297 }),
298 );
299
300 it.effect("rejects unauthenticated requests", () =>
301 Effect.gen(function* () {
302 const { post } = yield* linkProofClient;
303 const response = yield* post("/api/connect/link-proof", proofRequest);
304 expect(response.status).toBe(401);
305 }),
306 );
307});