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...@@ -14,6 +14,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
14import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";14import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
15import { SessionCredentialService } from "./Services/SessionCredentialService.ts";15import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
16import { deriveAuthClientMetadata } from "./utils.ts";16import { deriveAuthClientMetadata } from "./utils.ts";
17import { browserApiCorsHeaders } from "../httpCors.ts";
1718
18export const respondToAuthError = (error: AuthError) =>19export const respondToAuthError = (error: AuthError) =>
19 Effect.gen(function* () {20 Effect.gen(function* () {
...@@ -27,7 +28,7 @@ export const respondToAuthError = (error: AuthError) =>...@@ -27,7 +28,7 @@ export const respondToAuthError = (error: AuthError) =>
27 {28 {
28 error: error.message,29 error: error.message,
29 },30 },
30 { status: error.status ?? 500 },31 { status: error.status ?? 500, headers: browserApiCorsHeaders },
31 );32 );
32 });33 });
3334
...@@ -38,7 +39,10 @@ export const authSessionRouteLayer = HttpRouter.add(...@@ -38,7 +39,10 @@ export const authSessionRouteLayer = HttpRouter.add(
38 const request = yield* HttpServerRequest.HttpServerRequest;39 const request = yield* HttpServerRequest.HttpServerRequest;
39 const serverAuth = yield* ServerAuth;40 const serverAuth = yield* ServerAuth;
40 const session = yield* serverAuth.getSessionState(request);41 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 });
42 }),46 }),
43);47);
4448
...@@ -81,7 +85,10 @@ export const authBootstrapRouteLayer = HttpRouter.add(...@@ -81,7 +85,10 @@ export const authBootstrapRouteLayer = HttpRouter.add(
81 deriveAuthClientMetadata({ request }),85 deriveAuthClientMetadata({ request }),
82 );86 );
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(
85 HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {92 HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
86 expires: DateTime.toDate(result.response.expiresAt),93 expires: DateTime.toDate(result.response.expiresAt),
87 httpOnly: true,94 httpOnly: true,
...@@ -114,6 +121,7 @@ export const authBearerBootstrapRouteLayer = HttpRouter.add(...@@ -114,6 +121,7 @@ export const authBearerBootstrapRouteLayer = HttpRouter.add(
114 );121 );
115 return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {122 return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
116 status: 200,123 status: 200,
124 headers: browserApiCorsHeaders,
117 });125 });
118 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),126 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
119);127);
...@@ -128,6 +136,7 @@ export const authWebSocketTokenRouteLayer = HttpRouter.add(...@@ -128,6 +136,7 @@ export const authWebSocketTokenRouteLayer = HttpRouter.add(
128 const result = yield* serverAuth.issueWebSocketToken(session);136 const result = yield* serverAuth.issueWebSocketToken(session);
129 return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {137 return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
130 status: 200,138 status: 200,
139 headers: browserApiCorsHeaders,
131 });140 });
132 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),141 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
133);142);
apps/server/src/http.ts+11-3
...@@ -28,6 +28,11 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve...@@ -28,6 +28,11 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve
28import { ServerAuth } from "./auth/Services/ServerAuth.ts";28import { ServerAuth } from "./auth/Services/ServerAuth.ts";
29import { respondToAuthError } from "./auth/http.ts";29import { respondToAuthError } from "./auth/http.ts";
30import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";30import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
31import {
32 browserApiCorsAllowedHeaders,
33 browserApiCorsAllowedMethods,
34 browserApiCorsHeaders,
35} from "./httpCors.ts";
3136
32const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";37const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
33const 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>`;38const 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";...@@ -35,8 +40,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
35const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);40const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
3641
37export const browserApiCorsLayer = HttpRouter.cors({42export const browserApiCorsLayer = HttpRouter.cors({
38 allowedMethods: ["GET", "POST", "OPTIONS"],43 allowedMethods: [...browserApiCorsAllowedMethods],
39 allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],44 allowedHeaders: [...browserApiCorsAllowedHeaders],
40 maxAge: 600,45 maxAge: 600,
41});46});
4247
...@@ -69,7 +74,10 @@ export const serverEnvironmentRouteLayer = HttpRouter.add(...@@ -69,7 +74,10 @@ export const serverEnvironmentRouteLayer = HttpRouter.add(
69 const descriptor = yield* Effect.service(ServerEnvironment).pipe(74 const descriptor = yield* Effect.service(ServerEnvironment).pipe(
70 Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),75 Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
71 );76 );
72 return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });77 return HttpServerResponse.jsonUnsafe(descriptor, {
78 status: 200,
79 headers: browserApiCorsHeaders,
80 });
73 }),81 }),
74);82);
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 = (...@@ -802,7 +802,12 @@ const bootstrapBrowserSession = (
802 };802 };
803 });803 });
804804
805const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>805const bootstrapBearerSession = (
806 credential = defaultDesktopBootstrapToken,
807 options?: {
808 readonly headers?: Record<string, string>;
809 },
810) =>
806 Effect.gen(function* () {811 Effect.gen(function* () {
807 const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer");812 const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer");
808 const response = yield* Effect.promise(() =>813 const response = yield* Effect.promise(() =>
...@@ -810,6 +815,7 @@ const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>...@@ -810,6 +815,7 @@ const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
810 method: "POST",815 method: "POST",
811 headers: {816 headers: {
812 "content-type": "application/json",817 "content-type": "application/json",
818 ...options?.headers,
813 },819 },
814 body: JSON.stringify({820 body: JSON.stringify({
815 credential,821 credential,
...@@ -885,6 +891,22 @@ const splitHeaderTokens = (value: string | null) =>...@@ -885,6 +891,22 @@ const splitHeaderTokens = (value: string | null) =>
885 .filter((token) => token.length > 0)891 .filter((token) => token.length > 0)
886 .toSorted();892 .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
888const getWsServerUrl = (910const getWsServerUrl = (
889 pathname = "",911 pathname = "",
890 options?: { authenticated?: boolean; credential?: string },912 options?: { authenticated?: boolean; credential?: string },
...@@ -1006,6 +1028,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1006,6 +1028,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1006 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1028 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1007 );1029 );
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
1009 it.effect("reports unauthenticated session state without requiring auth", () =>1053 it.effect("reports unauthenticated session state without requiring auth", () =>
1010 Effect.gen(function* () {1054 Effect.gen(function* () {
1011 yield* buildAppUnderTest();1055 yield* buildAppUnderTest();
...@@ -1129,6 +1173,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1129,6 +1173,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1129 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1173 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1130 );1174 );
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
1132 it.effect(1232 it.effect(
1133 "responds to remote auth websocket-token preflight requests with authorization CORS headers",1233 "responds to remote auth websocket-token preflight requests with authorization CORS headers",
1134 () =>1234 () =>
...@@ -1140,7 +1240,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1140,7 +1240,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1140 fetch(wsTokenUrl, {1240 fetch(wsTokenUrl, {
1141 method: "OPTIONS",1241 method: "OPTIONS",
1142 headers: {1242 headers: {
1143 origin: "http://192.168.86.35:3773",1243 origin: crossOriginClientOrigin,
1144 "access-control-request-method": "POST",1244 "access-control-request-method": "POST",
1145 "access-control-request-headers": "authorization",1245 "access-control-request-headers": "authorization",
1146 },1246 },
...@@ -1148,18 +1248,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1148,18 +1248,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1148 );1248 );
11491249
1150 assert.equal(response.status, 204);1250 assert.equal(response.status, 204);
1151 assert.equal(response.headers.get("access-control-allow-origin"), "*");1251 assertBrowserApiCorsHeaders(response.headers);
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 ]);
1163 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1252 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1164 );1253 );
11651254
...@@ -1172,7 +1261,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1172,7 +1261,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1172 fetch(wsTokenUrl, {1261 fetch(wsTokenUrl, {
1173 method: "POST",1262 method: "POST",
1174 headers: {1263 headers: {
1175 origin: "http://192.168.86.35:3773",1264 origin: crossOriginClientOrigin,
1176 },1265 },
1177 }),1266 }),
1178 );1267 );
...@@ -1181,7 +1270,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1181,7 +1270,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1181 };1270 };
11821271
1183 assert.equal(response.status, 401);1272 assert.equal(response.status, 401);
1184 assert.equal(response.headers.get("access-control-allow-origin"), "*");1273 assertBrowserApiCorsHeaders(response.headers);
1185 assert.equal(body.error, "Authentication required.");1274 assert.equal(body.error, "Authentication required.");
1186 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1275 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1187 );1276 );