Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand All @@ -25,7 +26,7 @@ export const respondToAuthError = (error: AuthError) =>
{
error: error.message,
},
{ status: error.status ?? 500 },
{ status: error.status ?? 500, headers: browserApiCorsHeaders },
);
});

Expand All @@ -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,
});
}),
);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))),
);
Expand All @@ -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))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,20 @@ 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 = `<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>`;
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,
});

Expand Down Expand Up @@ -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,
});
}),
);

Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/httpCors.ts
Original file line number Diff line number Diff line change
@@ -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;
121 changes: 105 additions & 16 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,14 +796,20 @@ const bootstrapBrowserSession = (
};
});

const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
const bootstrapBearerSession = (
credential = defaultDesktopBootstrapToken,
options?: {
readonly headers?: Record<string, string>;
},
) =>
Effect.gen(function* () {
const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer");
const response = yield* Effect.promise(() =>
fetch(bootstrapUrl, {
method: "POST",
headers: {
"content-type": "application/json",
...options?.headers,
},
body: JSON.stringify({
credential,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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",
() =>
Expand All @@ -1128,26 +1228,15 @@ 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",
},
}),
);

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

Expand All @@ -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,
},
}),
);
Expand All @@ -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)),
);
Expand Down
Loading