1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 AuthSessionId,
4 AuthTokenExchangeGrantType,
5 AuthEnvironmentBootstrapTokenType,
6 AuthAccessTokenType,
7 AuthAccessTokenResult,
8 EnvironmentAuthenticatedAuth,
9 EnvironmentHttpApi,
10} from "@t3tools/contracts";
11import { expect, it } from "@effect/vitest";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Redacted from "effect/Redacted";
17import * as Schema from "effect/Schema";
18import * as Tracer from "effect/Tracer";
19import { HttpServerRequest } from "effect/http";
20import * as Etag from "effect/http/Etag";
21import * as HttpPlatform from "effect/http/HttpPlatform";
22import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
23import * as HttpApi from "effect/http-api/HttpApi";
24import * as HttpRouter from "effect/http/HttpRouter";
25
26import * as ServerConfig from "../config.ts";
27import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
28import * as SqlitePersistence from "../persistence/Sqlite.ts";
29import * as EnvironmentAuth from "./EnvironmentAuth.ts";
30import * as ServerSecretStore from "./ServerSecretStore.ts";
31import * as AuthHttp from "./http.ts";
32
33const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({
34 get: () => Effect.succeedNone,
35 set: () => Effect.void,
36 create: () => Effect.void,
37 getOrCreateRandom: () => Effect.die("Not used by these routes."),
38 remove: () => Effect.void,
39});
40
41const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough";
42class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}
43
44const layerConfig = Layer.effect(
45 ServerConfig.ServerConfig,
46 Effect.gen(function* () {
47 const config = yield* ServerConfig.ServerConfig;
48 return {
49 ...config,
50 mode: "web",
51 devUrl: new URL("http://127.0.0.1:5173"),
52 devAuthToken: Redacted.make(DEV_TOKEN),
53 } satisfies ServerConfig.ServerConfig["Service"];
54 }),
55).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-http-test-" })));
56
57const layerEnvironmentAuth = EnvironmentAuth.layer.pipe(
58 Layer.provide(SqlitePersistence.layerMemory),
59 Layer.provide(ServerSecretStore.layer),
60 Layer.provide(ServerEnvironment.layerIdentity),
61 Layer.provide(layerConfig),
62);
63const layerRoutes = HttpApiBuilder.layer(AuthTestApi).pipe(
64 Layer.provide(AuthHttp.layer),
65 Layer.provide(AuthHttp.layerAuthenticatedAuth),
66 Layer.provideMerge(layerEnvironmentAuth),
67 Layer.provide(layerConfig),
68 Layer.provideMerge(
69 HttpPlatform.layer.pipe(
70 Layer.provideMerge(NodeServices.layer),
71 Layer.provideMerge(Etag.layerWeak),
72 ),
73 ),
74 Layer.provide(NodeServices.layer),
75);
76
77const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
78const decodeAccessToken = Schema.decodeUnknownSync(AuthAccessTokenResult);
79const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, string>>) =>
80 new Request(`http://127.0.0.1${path}`, {
81 method: "POST",
82 headers: { "content-type": "application/json", ...headers },
83 body: encodeJson(body),
84 });
85
86it.effect("sets the selected browser session cookies through the HTTP route", () =>
87 Effect.gen(function* () {
88 const crypto = yield* Crypto.Crypto;
89 const requestContext = Context.make(Crypto.Crypto, crypto).pipe(
90 Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore),
91 );
92 return yield* Effect.acquireUseRelease(
93 Effect.sync(
94 () =>
95 [
96 HttpRouter.toWebHandler(layerRoutes, { disableLogger: true }),
97 HttpRouter.toWebHandler(layerRoutes, { disableLogger: true }),
98 ] as const,
99 ),
100 ([environmentA, environmentB]) =>
101 Effect.tryPromise(async () => {
102 const devResponse = await environmentA.handler(
103 postJson("/api/auth/browser-session", { credential: DEV_TOKEN }),
104 requestContext,
105 );
106 expect(devResponse.status).toBe(200);
107 const retiredScopeResponse = await environmentA.handler(
108 new Request("http://127.0.0.1/oauth/token", {
109 method: "POST",
110 body: new URLSearchParams({
111 grant_type: AuthTokenExchangeGrantType,
112 subject_token: DEV_TOKEN,
113 subject_token_type: AuthEnvironmentBootstrapTokenType,
114 requested_token_type: AuthAccessTokenType,
115 scope: "review:write",
116 }),
117 }),
118 requestContext,
119 );
120 expect(retiredScopeResponse.status).toBe(400);
121 expect(await retiredScopeResponse.json()).toMatchObject({ reason: "invalid_scope" });
122
123 const devCookies = devResponse.headers.getSetCookie();
124 const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));
125 expect(devCookie).toContain("HttpOnly");
126 expect(devCookie).toContain(`=${DEV_TOKEN};`);
127 expect(devCookies).toContainEqual(
128 expect.stringMatching(/^t3_session_[^=]*=;.*Max-Age=0/),
129 );
130 const devCookieHeader = devCookie?.split(";", 1)[0] ?? "";
131 const environmentBSession = await environmentB.handler(
132 new Request("http://127.0.0.1/api/auth/session", {
133 headers: { cookie: devCookieHeader },
134 }),
135 requestContext,
136 );
137 expect(environmentBSession.status).toBe(200);
138 expect(await environmentBSession.json()).toMatchObject({ authenticated: true });
139
140 const pairingResponse = await environmentA.handler(
141 postJson(
142 "/api/auth/pairing-token",
143 { scopes: ["orchestration:read"] },
144 { cookie: devCookieHeader },
145 ),
146 requestContext,
147 );
148 expect(pairingResponse.status).toBe(200);
149 const pairing = (await pairingResponse.json()) as { credential: string };
150 const restrictedResponse = await environmentA.handler(
151 postJson("/api/auth/browser-session", { credential: pairing.credential }),
152 requestContext,
153 );
154 expect(restrictedResponse.status).toBe(200);
155 const restrictedCookies = restrictedResponse.headers.getSetCookie();
156 expect(restrictedCookies).toHaveLength(1);
157 expect(restrictedCookies[0]).toMatch(/^t3_session_/);
158 expect(restrictedCookies[0]).not.toContain("t3_dev_session_");
159 }),
160 ([environmentA, environmentB]) =>
161 Effect.promise(() => Promise.all([environmentA.dispose(), environmentB.dispose()])),
162 );
163 }).pipe(Effect.provide(NodeServices.layer)),
164);
165
166it.effect("keeps authenticated connection requests on the local tracer", () =>
167 Effect.gen(function* () {
168 const localSpans: Array<string> = [];
169 const collect = (into: Array<string>) =>
170 Tracer.make({
171 span: (options) => {
172 into.push(options.name);
173 return new Tracer.NativeSpan(options);
174 },
175 });
176 // "DPoP connect" is a T3 Connect session; any other DPoP token is rejected.
177 const environmentAuth = {
178 authenticateHttpRequest: (request: HttpServerRequest.HttpServerRequest) =>
179 (request.headers.authorization === "DPoP forged"
180 ? Effect.fail(
181 new EnvironmentAuth.ServerAuthInvalidCredentialError({ diagnostic: "forged" }),
182 )
183 : Effect.succeed({
184 sessionId: AuthSessionId.make("session-1"),
185 subject:
186 request.headers.authorization === "DPoP connect"
187 ? "cloud-connect"
188 : "cli-issued-session",
189 method: "bearer-access-token" as const,
190 scopes: ["orchestration:read" as const],
191 })
192 ).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")),
193 } as unknown as EnvironmentAuth.EnvironmentAuth["Service"];
194 const middleware = yield* Layer.build(AuthHttp.layerAuthenticatedAuth).pipe(
195 Effect.provideService(EnvironmentAuth.EnvironmentAuth, environmentAuth),
196 Effect.map(Context.get(EnvironmentAuthenticatedAuth)),
197 );
198 const handle = (authorization: string) =>
199 (
200 middleware as unknown as (
201 effect: Effect.Effect<void>,
202 ) => Effect.Effect<void, Error, HttpServerRequest.HttpServerRequest>
203 )(Effect.void.pipe(Effect.withSpan("environment.handler"))).pipe(
204 Effect.ignore,
205 Effect.provideService(
206 HttpServerRequest.HttpServerRequest,
207 HttpServerRequest.fromWeb(
208 new Request("https://environment.example.test/api/orchestration/shell", {
209 headers: {
210 authorization,
211 traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
212 },
213 }),
214 ),
215 ),
216 Effect.withTracer(collect(localSpans)),
217 );
218
219 yield* handle("DPoP connect");
220 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]);
221
222 localSpans.length = 0;
223 yield* handle("DPoP forged");
224 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]);
225
226 localSpans.length = 0;
227 yield* handle("Bearer access-token");
228 expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]);
229 }).pipe(Effect.scoped),
230);
231
232it.effect("delegated credentials cannot mint an independent grant", () =>
233 Effect.gen(function* () {
234 const crypto = yield* Crypto.Crypto;
235 const requestContext = Context.make(Crypto.Crypto, crypto).pipe(
236 Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore),
237 );
238 return yield* Effect.acquireUseRelease(
239 Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })),
240 (server) =>
241 Effect.promise(async () => {
242 const response = await server.handler(
243 postJson(
244 "/api/auth/delegated-session",
245 {
246 scopes: ["orchestration:read", "access:write"],
247 },
248 { authorization: `Bearer ${DEV_TOKEN}` },
249 ),
250 requestContext,
251 );
252 expect(response.status).toBe(200);
253 expect(response.headers.get("cache-control")).toContain("no-store");
254 const issued = decodeAccessToken(await response.json());
255 const authorization = `Bearer ${issued.access_token}`;
256 for (const path of ["/api/auth/pairing-token", "/api/auth/delegated-session"]) {
257 const rejected = await server.handler(
258 postJson(path, { scopes: ["orchestration:read"] }, { authorization }),
259 requestContext,
260 );
261 expect(rejected.status).toBe(400);
262 expect(await rejected.json()).toMatchObject({ reason: "scope_not_granted" });
263 }
264 }),
265 (server) => Effect.promise(() => server.dispose()),
266 );
267 }).pipe(Effect.provide(NodeServices.layer)),
268);