Skip to content
Source
Sign inSign up
Open History to see the last commit

http.test.ts

193 lines8.0 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 AuthTokenExchangeGrantType,
5 AuthEnvironmentBootstrapTokenType,
6 AuthAccessTokenType,
7 EnvironmentHttpApi,
8} from "@t3tools/contracts";
9import { expect, it } from "@effect/vitest";
10import * as Context from "effect/Context";
11import * as SecureRandom from "effect/crypto/SecureRandom";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Redacted from "effect/Redacted";
15import * as Schema from "effect/Schema";
16import * as Etag from "effect/http/Etag";
17import * as HttpPlatform from "effect/http/HttpPlatform";
18import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
19import * as HttpApi from "effect/http-api/HttpApi";
20import * as HttpRouter from "effect/http/HttpRouter";
21​
22import * as ServerConfig from "../config.ts";
23import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
24import * as SqlitePersistence from "../persistence/Sqlite.ts";
25import * as EnvironmentAuth from "./EnvironmentAuth.ts";
26import * as ServerSecretStore from "./ServerSecretStore.ts";
27import * as AuthHttp from "./http.ts";
28import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
29​
30const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough";
31class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}
32​
33const layerConfig = Layer.effect(
34 ServerConfig.ServerConfig,
35 Effect.gen(function* () {
36 const config = yield* ServerConfig.ServerConfig;
37 return {
38 ...config,
39 mode: "web",
40 devUrl: new URL("http://127.0.0.1:5173"),
41 devAuthToken: Redacted.make(DEV_TOKEN),
42 } satisfies ServerConfig.ServerConfig["Service"];
43 }),
44).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-http-test-" })));
45​
46const layerEnvironmentAuth = EnvironmentAuth.layer.pipe(
47 Layer.provide(ManagedPolicy.layerTest()),
48 Layer.provide(SqlitePersistence.layerMemory),
49 Layer.provide(ServerSecretStore.layer),
50 Layer.provide(ServerEnvironment.layerIdentity),
51 Layer.provide(layerConfig),
52);
53const layerRoutes = HttpApiBuilder.layer(AuthTestApi).pipe(
54 Layer.provide(AuthHttp.layer),
55 Layer.provide(AuthHttp.layerAuthenticatedAuth),
56 Layer.provideMerge(layerEnvironmentAuth),
57 Layer.provide(layerConfig),
58 Layer.provideMerge(
59 HttpPlatform.layer.pipe(
60 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
61 Layer.provideMerge(Etag.layerWeak),
62 ),
63 ),
64 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
65);
66​
67const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
68const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, string>>) =>
69 new Request(`http://127.0.0.1${path}`, {
70 method: "POST",
71 headers: { "content-type": "application/json", ...headers },
72 body: encodeJson(body),
73 });
74​
75it.effect("sets the selected browser session cookies through the HTTP route", () =>
76 Effect.gen(function* () {
77 const random = yield* SecureRandom.SecureRandom;
78 const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({
79 get: () => Effect.succeedNone,
80 set: () => Effect.void,
81 create: () => Effect.void,
82 getOrCreateRandom: () => Effect.die("Not used by these routes."),
83 remove: () => Effect.void,
84 });
85 const requestContext = Context.make(SecureRandom.SecureRandom, random).pipe(
86 Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore),
87 );
88 return yield* Effect.acquireUseRelease(
89 Effect.sync(
90 () =>
91 [
92 HttpRouter.toWebHandler(layerRoutes, { disableLogger: true }),
93 HttpRouter.toWebHandler(layerRoutes, { disableLogger: true }),
94 ] as const,
95 ),
96 ([environmentA, environmentB]) =>
97 Effect.promise(async () => {
98 const devResponse = await environmentA.handler(
99 postJson("/api/auth/browser-session", { credential: DEV_TOKEN }),
100 requestContext,
101 );
102 expect(devResponse.status).toBe(200);
103 const retiredScopeResponse = await environmentA.handler(
104 new Request("http://127.0.0.1/oauth/token", {
105 method: "POST",
106 body: new URLSearchParams({
107 grant_type: AuthTokenExchangeGrantType,
108 subject_token: DEV_TOKEN,
109 subject_token_type: AuthEnvironmentBootstrapTokenType,
110 requested_token_type: AuthAccessTokenType,
111 scope: "review:write",
112 }),
113 }),
114 requestContext,
115 );
116 expect(retiredScopeResponse.status).toBe(400);
117 expect(await retiredScopeResponse.json()).toMatchObject({ reason: "invalid_scope" });
118​
119 const devCookies = devResponse.headers.getSetCookie();
120 const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));
121 expect(devCookie).toContain("HttpOnly");
122 expect(devCookie).toContain(`=${DEV_TOKEN};`);
123 expect(devCookies).toContainEqual(
124 expect.stringMatching(/^t3_session_[^=]*=;.*Max-Age=0/),
125 );
126 const devCookieHeader = devCookie?.split(";", 1)[0] ?? "";
127 const environmentBSession = await environmentB.handler(
128 new Request("http://127.0.0.1/api/auth/session", {
129 headers: { cookie: devCookieHeader },
130 }),
131 requestContext,
132 );
133 expect(environmentBSession.status).toBe(200);
134 expect(await environmentBSession.json()).toMatchObject({ authenticated: true });
135​
136 const pairingResponse = await environmentA.handler(
137 postJson(
138 "/api/auth/pairing-token",
139 { scopes: ["orchestration:read"] },
140 { cookie: devCookieHeader },
141 ),
142 requestContext,
143 );
144 expect(pairingResponse.status).toBe(200);
145 const pairing = (await pairingResponse.json()) as { credential: string };
146 const restrictedResponse = await environmentA.handler(
147 postJson("/api/auth/browser-session", { credential: pairing.credential }),
148 requestContext,
149 );
150 expect(restrictedResponse.status).toBe(200);
151 const restrictedCookies = restrictedResponse.headers.getSetCookie();
152 expect(restrictedCookies).toHaveLength(1);
153 expect(restrictedCookies[0]).toMatch(/^t3_session_/);
154 expect(restrictedCookies[0]).not.toContain("t3_dev_session_");
155 }),
156 ([environmentA, environmentB]) =>
157 Effect.promise(() => Promise.all([environmentA.dispose(), environmentB.dispose()])),
158 );
159 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
160);
161​
162it.effect.each(["DPoP", "dpop"])("challenges a refused %s request with DPoP", (scheme) =>
163 Effect.gen(function* () {
164 const random = yield* SecureRandom.SecureRandom;
165 const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({
166 get: () => Effect.succeedNone,
167 set: () => Effect.void,
168 create: () => Effect.void,
169 getOrCreateRandom: () => Effect.die("Not used by these routes."),
170 remove: () => Effect.void,
171 });
172 const requestContext = Context.make(SecureRandom.SecureRandom, random).pipe(
173 Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore),
174 );
175 return yield* Effect.acquireUseRelease(
176 Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })),
177 (environment) =>
178 Effect.promise(async () => {
179 const refused = await environment.handler(
180 postJson(
181 "/api/auth/pairing-token",
182 { scopes: ["orchestration:read"] },
183 { authorization: `${scheme} not-a-valid-token` },
184 ),
185 requestContext,
186 );
187 expect(refused.status).toBe(401);
188 expect(refused.headers.get("www-authenticate")).toBe("DPoP");
189 }),
190 (environment) => Effect.promise(() => environment.dispose()),
191 );
192 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
193);