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