diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
index 76c14646e98..dffa477dcc9 100644
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -12,6 +12,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
import { deriveAuthClientMetadata } from "./utils.ts";
+import { browserApiCorsHeaders } from "../httpCors.ts";
export const respondToAuthError = (error: AuthError) =>
Effect.gen(function* () {
@@ -25,7 +26,7 @@ export const respondToAuthError = (error: AuthError) =>
{
error: error.message,
},
- { status: error.status ?? 500 },
+ { status: error.status ?? 500, headers: browserApiCorsHeaders },
);
});
@@ -36,7 +37,10 @@ export const authSessionRouteLayer = HttpRouter.add(
const request = yield* HttpServerRequest.HttpServerRequest;
const serverAuth = yield* ServerAuth;
const session = yield* serverAuth.getSessionState(request);
- return HttpServerResponse.jsonUnsafe(session, { status: 200 });
+ return HttpServerResponse.jsonUnsafe(session, {
+ status: 200,
+ headers: browserApiCorsHeaders,
+ });
}),
);
@@ -79,7 +83,10 @@ export const authBootstrapRouteLayer = HttpRouter.add(
deriveAuthClientMetadata({ request }),
);
- return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe(
+ return yield* HttpServerResponse.jsonUnsafe(result.response, {
+ status: 200,
+ headers: browserApiCorsHeaders,
+ }).pipe(
HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
expires: DateTime.toDate(result.response.expiresAt),
httpOnly: true,
@@ -112,6 +119,7 @@ export const authBearerBootstrapRouteLayer = HttpRouter.add(
);
return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
status: 200,
+ headers: browserApiCorsHeaders,
});
}).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
);
@@ -126,6 +134,7 @@ export const authWebSocketTokenRouteLayer = HttpRouter.add(
const result = yield* serverAuth.issueWebSocketToken(session);
return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
status: 200,
+ headers: browserApiCorsHeaders,
});
}).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
);
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index 23ee0f54cf8..e3a94f594e6 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -24,6 +24,11 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve
import { ServerAuth } from "./auth/Services/ServerAuth.ts";
import { respondToAuthError } from "./auth/http.ts";
import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
+import {
+ browserApiCorsAllowedHeaders,
+ browserApiCorsAllowedMethods,
+ browserApiCorsHeaders,
+} from "./httpCors.ts";
const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
const FALLBACK_PROJECT_FAVICON_SVG = ``;
@@ -31,8 +36,8 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
export const browserApiCorsLayer = HttpRouter.cors({
- allowedMethods: ["GET", "POST", "OPTIONS"],
- allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
+ allowedMethods: [...browserApiCorsAllowedMethods],
+ allowedHeaders: [...browserApiCorsAllowedHeaders],
maxAge: 600,
});
@@ -65,7 +70,10 @@ export const serverEnvironmentRouteLayer = HttpRouter.add(
const descriptor = yield* Effect.service(ServerEnvironment).pipe(
Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
);
- return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });
+ return HttpServerResponse.jsonUnsafe(descriptor, {
+ status: 200,
+ headers: browserApiCorsHeaders,
+ });
}),
);
diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts
new file mode 100644
index 00000000000..e44486d3c4b
--- /dev/null
+++ b/apps/server/src/httpCors.ts
@@ -0,0 +1,13 @@
+export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;
+export const browserApiCorsAllowedHeaders = [
+ "authorization",
+ "b3",
+ "traceparent",
+ "content-type",
+] as const;
+
+export const browserApiCorsHeaders = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "),
+ "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "),
+} as const;
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 32261dd618b..c20a3f53229 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -796,7 +796,12 @@ const bootstrapBrowserSession = (
};
});
-const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
+const bootstrapBearerSession = (
+ credential = defaultDesktopBootstrapToken,
+ options?: {
+ readonly headers?: Record;
+ },
+) =>
Effect.gen(function* () {
const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer");
const response = yield* Effect.promise(() =>
@@ -804,6 +809,7 @@ const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
method: "POST",
headers: {
"content-type": "application/json",
+ ...options?.headers,
},
body: JSON.stringify({
credential,
@@ -873,6 +879,22 @@ const splitHeaderTokens = (value: string | null) =>
.filter((token) => token.length > 0)
.toSorted();
+const assertBrowserApiCorsHeaders = (headers: Headers) => {
+ assert.equal(headers.get("access-control-allow-origin"), "*");
+ assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-methods")), [
+ "GET",
+ "OPTIONS",
+ "POST",
+ ]);
+ assert.deepEqual(splitHeaderTokens(headers.get("access-control-allow-headers")), [
+ "authorization",
+ "b3",
+ "content-type",
+ "traceparent",
+ ]);
+};
+const crossOriginClientOrigin = "http://remote-client.test:3773";
+
const getWsServerUrl = (
pathname = "",
options?: { authenticated?: boolean; credential?: string },
@@ -994,6 +1016,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("includes CORS headers on public environment descriptor responses", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const url = yield* getHttpServerUrl("/.well-known/t3/environment");
+ const response = yield* Effect.promise(() =>
+ fetch(url, {
+ headers: {
+ origin: crossOriginClientOrigin,
+ },
+ }),
+ );
+ const body = (yield* Effect.promise(() =>
+ response.json(),
+ )) as typeof testEnvironmentDescriptor;
+
+ assert.equal(response.status, 200);
+ assertBrowserApiCorsHeaders(response.headers);
+ assert.deepEqual(body, testEnvironmentDescriptor);
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("reports unauthenticated session state without requiring auth", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
@@ -1117,6 +1161,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+ it.effect("includes CORS headers on remote auth success responses", () =>
+ Effect.gen(function* () {
+ yield* buildAppUnderTest();
+
+ const origin = crossOriginClientOrigin;
+ const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBearerSession(
+ defaultDesktopBootstrapToken,
+ {
+ headers: { origin },
+ },
+ );
+
+ assert.equal(bootstrapResponse.status, 200);
+ assertBrowserApiCorsHeaders(bootstrapResponse.headers);
+ assert.equal(bootstrapBody.authenticated, true);
+ assert.equal(typeof bootstrapBody.sessionToken, "string");
+
+ const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
+ const sessionResponse = yield* Effect.promise(() =>
+ fetch(sessionUrl, {
+ headers: {
+ authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
+ origin,
+ },
+ }),
+ );
+ const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as {
+ readonly authenticated: boolean;
+ readonly sessionMethod?: string;
+ };
+
+ assert.equal(sessionResponse.status, 200);
+ assertBrowserApiCorsHeaders(sessionResponse.headers);
+ assert.equal(sessionBody.authenticated, true);
+ assert.equal(sessionBody.sessionMethod, "bearer-session-token");
+
+ const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token");
+ const wsTokenResponse = yield* Effect.promise(() =>
+ fetch(wsTokenUrl, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`,
+ origin,
+ },
+ }),
+ );
+ const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as {
+ readonly token: string;
+ };
+
+ assert.equal(wsTokenResponse.status, 200);
+ assertBrowserApiCorsHeaders(wsTokenResponse.headers);
+ assert.equal(typeof wsTokenBody.token, "string");
+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect(
"responds to remote auth websocket-token preflight requests with authorization CORS headers",
() =>
@@ -1128,7 +1228,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
fetch(wsTokenUrl, {
method: "OPTIONS",
headers: {
- origin: "http://192.168.86.35:3773",
+ origin: crossOriginClientOrigin,
"access-control-request-method": "POST",
"access-control-request-headers": "authorization",
},
@@ -1136,18 +1236,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
);
assert.equal(response.status, 204);
- assert.equal(response.headers.get("access-control-allow-origin"), "*");
- assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [
- "GET",
- "OPTIONS",
- "POST",
- ]);
- assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [
- "authorization",
- "b3",
- "content-type",
- "traceparent",
- ]);
+ assertBrowserApiCorsHeaders(response.headers);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
@@ -1160,7 +1249,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
fetch(wsTokenUrl, {
method: "POST",
headers: {
- origin: "http://192.168.86.35:3773",
+ origin: crossOriginClientOrigin,
},
}),
);
@@ -1169,7 +1258,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
};
assert.equal(response.status, 401);
- assert.equal(response.headers.get("access-control-allow-origin"), "*");
+ assertBrowserApiCorsHeaders(response.headers);
assert.equal(body.error, "Authentication required.");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);