Skip to content

Commit e0f3abd

Browse files
authored
Fix remote pairing CORS responses (#2594)
1 parent 34ec8a8 commit e0f3abd

4 files changed

Lines changed: 141 additions & 22 deletions

File tree

apps/server/src/auth/http.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab
1414
import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
1515
import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
1616
import { deriveAuthClientMetadata } from "./utils.ts";
17+
import { browserApiCorsHeaders } from "../httpCors.ts";
1718

1819
export 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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,20 @@ import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolve
2828
import { ServerAuth } from "./auth/Services/ServerAuth.ts";
2929
import { respondToAuthError } from "./auth/http.ts";
3030
import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
31+
import {
32+
browserApiCorsAllowedHeaders,
33+
browserApiCorsAllowedMethods,
34+
browserApiCorsHeaders,
35+
} from "./httpCors.ts";
3136

3237
const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
3338
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>`;
3439
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
3540
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
3641

3742
export 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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;
2+
export const browserApiCorsAllowedHeaders = [
3+
"authorization",
4+
"b3",
5+
"traceparent",
6+
"content-type",
7+
] as const;
8+
9+
export 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

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -802,14 +802,20 @@ const bootstrapBrowserSession = (
802802
};
803803
});
804804

805-
const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) =>
805+
const 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(() =>
809814
fetch(bootstrapUrl, {
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

894+
const 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+
};
908+
const crossOriginClientOrigin = "http://remote-client.test:3773";
909+
888910
const 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,26 +1240,15 @@ 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
},
11471247
}),
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
);

0 commit comments

Comments
 (0)