authorgravatar for ben@vargas.comBen Vargas <ben@vargas.com> 2026-05-08 17:52:47-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-08 23:52:47+00:00
loge0f3abd1a4e14112498c48077e387e5eeb5c629d
treed716337a368314c7082f9e7f3152b9ea4cce4cf6
parent34ec8a86fb3a76f5d65e6805dcf98d785536b6f8
signature Signed by PGP key B5690EEEBB952194

Fix remote pairing CORS responses (#2594)


4 files changed, 141 insertions(+), 22 deletions(-)

apps/server/src/auth/http.ts+12-3
......@@ -14,6 +14,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
1414import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
1515import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
1616import { deriveAuthClientMetadata } from "./utils.ts";
17import { browserApiCorsHeaders } from "../httpCors.ts";
1718
1819export const respondToAuthError = (error: AuthError) =>
1920 Effect.gen(function* () {
......@@ -27,7 +28,7 @@ export const respondToAuthError = (error: AuthError) =>
2728 {
2829 error: error.message,
2930 },
30 { status: error.status ?? 500 },
31 { status: error.status ?? 500, headers: browserApiCorsHeaders },
3132 );
3233 });
3334
......@@ -38,7 +39,10 @@ export const authSessionRouteLayer = HttpRouter.add(
3839 const request = yield* HttpServerRequest.HttpServerRequest;
3940 const serverAuth = yield* ServerAuth;
4041 const session = yield* serverAuth.getSessionState(request);
41 return HttpServerResponse.jsonUnsafe(session, { status: 200 });
42 return HttpServerResponse.jsonUnsafe(session, {
43 status: 200,
44 headers: browserApiCorsHeaders,
45 });
4246 }),
4347);
4448
......@@ -81,7 +85,10 @@ export const authBootstrapRouteLayer = HttpRouter.add(
8185 deriveAuthClientMetadata({ request }),
8286 );
8387
84 return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe(
88 return yield* HttpServerResponse.jsonUnsafe(result.response, {
89 status: 200,
90 headers: browserApiCorsHeaders,
91 }).pipe(
8592 HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
8693 expires: DateTime.toDate(result.response.expiresAt),
8794 httpOnly: true,
......@@ -114,6 +121,7 @@ export const authBearerBootstrapRouteLayer = HttpRouter.add(
114121 );
115122 return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
116123 status: 200,
124 headers: browserApiCorsHeaders,
117125 });
118126 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
119127);
......@@ -128,6 +136,7 @@ export const authWebSocketTokenRouteLayer = HttpRouter.add(
128136 const result = yield* serverAuth.issueWebSocketToken(session);
129137 return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
130138 status: 200,
139 headers: browserApiCorsHeaders,
131140 });
132141 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
133142);
apps/server/src/http.ts+11-3
......@@ -28,6 +28,11 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve
2828import { ServerAuth } from "./auth/Services/ServerAuth.ts";
2929import { respondToAuthError } from "./auth/http.ts";
3030import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
31import {
32 browserApiCorsAllowedHeaders,
33 browserApiCorsAllowedMethods,
34 browserApiCorsHeaders,
35} from "./httpCors.ts";
3136
3237const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
3338const 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>`;
......@@ -35,8 +40,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
3540const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
3641
3742export const browserApiCorsLayer = HttpRouter.cors({
38 allowedMethods: ["GET", "POST", "OPTIONS"],
39 allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
43 allowedMethods: [...browserApiCorsAllowedMethods],
44 allowedHeaders: [...browserApiCorsAllowedHeaders],
4045 maxAge: 600,
4146});
4247
......@@ -69,7 +74,10 @@ export const serverEnvironmentRouteLayer = HttpRouter.add(
6974 const descriptor = yield* Effect.service(ServerEnvironment).pipe(
7075 Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
7176 );
72 return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });
77 return HttpServerResponse.jsonUnsafe(descriptor, {
78 status: 200,
79 headers: browserApiCorsHeaders,
80 });
7381 }),
7482);
7583
apps/server/src/httpCors.ts created+13
......@@ -0,0 +1,13 @@
1export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;
2export const browserApiCorsAllowedHeaders = [
3 "authorization",
4 "b3",
5 "traceparent",
6 "content-type",
7] as const;
8
9export const browserApiCorsHeaders = {
10 "access-control-allow-origin": "*",
11 "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "),
12 "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "),
13} as const;
apps/server/src/server.test.ts+105-16
......@@ -802,7 +802,12 @@ const bootstrapBrowserSession = (
802802 };
803803 });
804804
805const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
805const bootstrapBearerSession = (
806 credential = defaultDesktopBootstrapToken,
807 options?: {
808 readonly headers?: Record<string, string>;
809 },
810) =>
806811 Effect.gen(function* () {
807812 const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer");
808813 const response = yield* Effect.promise(() =>
......@@ -810,6 +815,7 @@ const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
810815 method: "POST",
811816 headers: {
812817 "content-type": "application/json",
818 ...options?.headers,
813819 },
814820 body: JSON.stringify({
815821 credential,
......@@ -885,6 +891,22 @@ const splitHeaderTokens = (value: string | null) =>
885891 .filter((token) => token.length > 0)
886892 .toSorted();
887893
894const assertBrowserApiCorsHeaders = (headers: Headers) => {
895 assert.equal(headers.get("access-control-allow-origin"), "*");
896 assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-methods")), [
897 "GET",
898 "OPTIONS",
899 "POST",
900 ]);
901 assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-headers")), [
902 "authorization",
903 "b3",
904 "content-type",
905 "traceparent",
906 ]);
907};
908const crossOriginClientOrigin = "http://remote-client.test:3773";
909
888910const getWsServerUrl = (
889911 pathname = "",
890912 options?: { authenticated?: boolean; credential?: string },
......@@ -1006,6 +1028,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10061028 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
10071029 );
10081030
1031 it.effect("includes CORS headers on public environment descriptor responses", () =>
1032 Effect.gen(function* () {
1033 yield* buildAppUnderTest();
1034
1035 const url = yield* getHttpServerUrl("/.well-known/t3/environment");
1036 const response = yield* Effect.promise(() =>
1037 fetch(url, {
1038 headers: {
1039 origin: crossOriginClientOrigin,
1040 },
1041 }),
1042 );
1043 const body = (yield* Effect.promise(() =>
1044 response.json(),
1045 )) as typeof testEnvironmentDescriptor;
1046
1047 assert.equal(response.status, 200);
1048 assertBrowserApiCorsHeaders(response.headers);
1049 assert.deepEqual(body, testEnvironmentDescriptor);
1050 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1051 );
1052
10091053 it.effect("reports unauthenticated session state without requiring auth", () =>
10101054 Effect.gen(function* () {
10111055 yield* buildAppUnderTest();
......@@ -1129,6 +1173,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11291173 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11301174 );
11311175
1176 it.effect("includes CORS headers on remote auth success responses", () =>
1177 Effect.gen(function* () {
1178 yield* buildAppUnderTest();
1179
1180 const origin = crossOriginClientOrigin;
1181 const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession(
1182 defaultDesktopBootstrapToken,
1183 {
1184 headers: { origin },
1185 },
1186 );
1187
1188 assert.equal(bootstrapResponse.status, 200);
1189 assertBrowserApiCorsHeaders(bootstrapResponse.headers);
1190 assert.equal(bootstrapBody.authenticated, true);
1191 assert.equal(typeof bootstrapBody.sessionToken, "string");
1192
1193 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1194 const sessionResponse = yield* Effect.promise(() =>
1195 fetch(sessionUrl, {
1196 headers: {
1197 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
1198 origin,
1199 },
1200 }),
1201 );
1202 const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as {
1203 readonly authenticated: boolean;
1204 readonly sessionMethod?: string;
1205 };
1206
1207 assert.equal(sessionResponse.status, 200);
1208 assertBrowserApiCorsHeaders(sessionResponse.headers);
1209 assert.equal(sessionBody.authenticated, true);
1210 assert.equal(sessionBody.sessionMethod, "bearer-session-token");
1211
1212 const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token");
1213 const wsTokenResponse = yield* Effect.promise(() =>
1214 fetch(wsTokenUrl, {
1215 method: "POST",
1216 headers: {
1217 authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
1218 origin,
1219 },
1220 }),
1221 );
1222 const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as {
1223 readonly token: string;
1224 };
1225
1226 assert.equal(wsTokenResponse.status, 200);
1227 assertBrowserApiCorsHeaders(wsTokenResponse.headers);
1228 assert.equal(typeof wsTokenBody.token, "string");
1229 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1230 );
1231
11321232 it.effect(
11331233 "responds to remote auth websocket-token preflight requests with authorization CORS headers",
11341234 () =>
......@@ -1140,7 +1240,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11401240 fetch(wsTokenUrl, {
11411241 method: "OPTIONS",
11421242 headers: {
1143 origin: "http://192.168.86.35:3773",
1243 origin: crossOriginClientOrigin,
11441244 "access-control-request-method": "POST",
11451245 "access-control-request-headers": "authorization",
11461246 },
......@@ -1148,18 +1248,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11481248 );
11491249
11501250 assert.equal(response.status, 204);
1151 assert.equal(response.headers.get("access-control-allow-origin"), "*");
1152 assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [
1153 "GET",
1154 "OPTIONS",
1155 "POST",
1156 ]);
1157 assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [
1158 "authorization",
1159 "b3",
1160 "content-type",
1161 "traceparent",
1162 ]);
1251 assertBrowserApiCorsHeaders(response.headers);
11631252 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11641253 );
11651254
......@@ -1172,7 +1261,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11721261 fetch(wsTokenUrl, {
11731262 method: "POST",
11741263 headers: {
1175 origin: "http://192.168.86.35:3773",
1264 origin: crossOriginClientOrigin,
11761265 },
11771266 }),
11781267 );
......@@ -1181,7 +1270,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11811270 };
11821271
11831272 assert.equal(response.status, 401);
1184 assert.equal(response.headers.get("access-control-allow-origin"), "*");
1273 assertBrowserApiCorsHeaders(response.headers);
11851274 assert.equal(body.error, "Authentication required.");
11861275 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11871276 );