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)), );