| ... | ... | @@ -802,7 +802,12 @@ const bootstrapBrowserSession = ( |
| 802 | 802 | }; |
| 803 | 803 | }); |
| 804 | 804 | |
| 805 | | const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => |
| 805 | const bootstrapBearerSession = ( |
| 806 | credential = defaultDesktopBootstrapToken, |
| 807 | options?: { |
| 808 | readonly headers?: Record<string, string>; |
| 809 | }, |
| 810 | ) => |
| 806 | 811 | Effect.gen(function* () { |
| 807 | 812 | const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer"); |
| 808 | 813 | const response = yield* Effect.promise(() => |
| ... | ... | @@ -810,6 +815,7 @@ const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => |
| 810 | 815 | method: "POST", |
| 811 | 816 | headers: { |
| 812 | 817 | "content-type": "application/json", |
| 818 | ...options?.headers, |
| 813 | 819 | }, |
| 814 | 820 | body: JSON.stringify({ |
| 815 | 821 | credential, |
| ... | ... | @@ -885,6 +891,22 @@ const splitHeaderTokens = (value: string | null) => |
| 885 | 891 | .filter((token) => token.length > 0) |
| 886 | 892 | .toSorted(); |
| 887 | 893 | |
| 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 | |
| 888 | 910 | const getWsServerUrl = ( |
| 889 | 911 | pathname = "", |
| 890 | 912 | options?: { authenticated?: boolean; credential?: string }, |
| ... | ... | @@ -1006,6 +1028,28 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1006 | 1028 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1007 | 1029 | ); |
| 1008 | 1030 | |
| 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 | 1053 | it.effect("reports unauthenticated session state without requiring auth", () => |
| 1010 | 1054 | Effect.gen(function* () { |
| 1011 | 1055 | yield* buildAppUnderTest(); |
| ... | ... | @@ -1129,6 +1173,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1129 | 1173 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1130 | 1174 | ); |
| 1131 | 1175 | |
| 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 | 1232 | it.effect( |
| 1133 | 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 | 1240 | fetch(wsTokenUrl, { |
| 1141 | 1241 | method: "OPTIONS", |
| 1142 | 1242 | headers: { |
| 1143 | | origin: "http://192.168.86.35:3773", |
| 1243 | origin: crossOriginClientOrigin, |
| 1144 | 1244 | "access-control-request-method": "POST", |
| 1145 | 1245 | "access-control-request-headers": "authorization", |
| 1146 | 1246 | }, |
| ... | ... | @@ -1148,18 +1248,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1148 | 1248 | ); |
| 1149 | 1249 | |
| 1150 | 1250 | 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); |
| 1163 | 1252 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1164 | 1253 | ); |
| 1165 | 1254 | |
| ... | ... | @@ -1172,7 +1261,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1172 | 1261 | fetch(wsTokenUrl, { |
| 1173 | 1262 | method: "POST", |
| 1174 | 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 | 1270 | }; |
| 1182 | 1271 | |
| 1183 | 1272 | assert.equal(response.status, 401); |
| 1184 | | assert.equal(response.headers.get("access-control-allow-origin"), "*"); |
| 1273 | assertBrowserApiCorsHeaders(response.headers); |
| 1185 | 1274 | assert.equal(body.error, "Authentication required."); |
| 1186 | 1275 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1187 | 1276 | ); |