1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { AuthOrchestrationReadScope, EnvironmentId } from "@t3tools/contracts";
4import { computeDpopAccessTokenHash, computeDpopJwkThumbprint } from "@t3tools/shared/dpop";
5import * as Clock from "effect/Clock";
6import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http";
10import { exportJWK, generateKeyPair, SignJWT } from "jose";
11import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
12import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
13import * as SessionStore from "../auth/SessionStore.ts";
14import * as ServerConfig from "../config.ts";
15import * as SqlitePersistence from "../persistence/Sqlite.ts";
16import * as ServerEnvironment from "./ServerEnvironment.ts";
17import * as Hub from "./Hub.ts";
18import * as HubHttp from "./HubHttp.ts";
19import * as HubProxy from "./HubProxy.ts";
20
21it.live(
22 "verifies DPoP against the prefixed hub URL, rejects replay and revoked clients, and never sends their proof to a leaf",
23 () =>
24 Effect.gen(function* () {
25 const config = ServerConfig.layerTest(process.cwd(), { prefix: "t3-hub-auth-test-" });
26 const services = yield* Layer.build(
27 EnvironmentAuth.layer.pipe(
28 Layer.provideMerge(SqlitePersistence.layerMemory),
29 Layer.provide(ServerSecretStore.layer),
30 Layer.provide(ServerEnvironment.layerIdentity),
31 Layer.provide(config),
32 ),
33 );
34 const auth = Context.get(services, EnvironmentAuth.EnvironmentAuth);
35 const sessions = Context.get(services, SessionStore.SessionStore);
36 const keys = yield* Effect.promise(() => generateKeyPair("ES256"));
37 const exported = yield* Effect.promise(() => exportJWK(keys.publicKey));
38 if (!exported.x || !exported.y) return yield* Effect.die("Missing public key coordinates");
39 const jwk = { kty: "EC" as const, crv: "P-256" as const, x: exported.x, y: exported.y };
40 const token = yield* sessions.issue({
41 method: "dpop-access-token",
42 subject: "phone",
43 proofKeyThumbprint: computeDpopJwkThumbprint(jwk),
44 scopes: [AuthOrchestrationReadScope],
45 });
46 const requests: Array<{ url: string; authorization?: string; proof?: string }> = [];
47 const hub = Hub.Hub.of({
48 authorize: () => Effect.succeed([AuthOrchestrationReadScope]),
49 descriptor: () => Effect.die("unused"),
50 directory: () =>
51 Effect.succeed({ hubEnvironmentId: EnvironmentId.make("hub"), environments: [] }),
52 manage: () => Effect.die("unused"),
53 connect: () =>
54 Effect.succeed({
55 url: "http://leaf.private/",
56 token: "leaf-private-token",
57 invalidated: Effect.never,
58 }),
59 });
60 const client = HttpClient.make((request) =>
61 Effect.sync(() => {
62 requests.push({
63 url: request.url,
64 ...(request.headers.authorization
65 ? { authorization: request.headers.authorization }
66 : {}),
67 ...(request.headers.dpop ? { proof: request.headers.dpop } : {}),
68 });
69 return HttpClientResponse.fromWeb(request, new Response("leaf response"));
70 }),
71 );
72 const dependencies = Layer.mergeAll(
73 Layer.succeedContext(services),
74 Layer.succeed(Hub.Hub, hub),
75 Layer.succeed(HttpClient.HttpClient, client),
76 );
77 const { handler, dispose } = HttpRouter.toWebHandler(
78 HubHttp.layer.pipe(
79 Layer.provideMerge(HubProxy.layer.pipe(Layer.provide(dependencies))),
80 Layer.provideMerge(dependencies),
81 ),
82 { disableLogger: true },
83 );
84 yield* Effect.addFinalizer(() => Effect.promise(dispose));
85 const publicUrl = "https://hub.example/environments/leaf/api/example";
86 const iat = Math.floor((yield* Clock.currentTimeMillis) / 1_000);
87 const proof = (url: string, id: string) =>
88 Effect.promise(() =>
89 new SignJWT({
90 htm: "GET",
91 htu: url,
92 jti: id,
93 iat,
94 ath: computeDpopAccessTokenHash(token.token),
95 })
96 .setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
97 .sign(keys.privateKey),
98 );
99 const request = (dpop: string) =>
100 Effect.promise(() =>
101 handler(
102 new Request(publicUrl, {
103 headers: {
104 authorization: `DPoP ${token.token}`,
105 host: "hub.example",
106 "x-forwarded-proto": "https",
107 dpop,
108 },
109 }),
110 ),
111 );
112 const valid = yield* proof(publicUrl, "first");
113 const response = yield* request(valid);
114 expect(response.status).toBe(200);
115 expect(yield* Effect.promise(() => response.text())).toBe("leaf response");
116 expect(requests).toEqual([
117 { url: "http://leaf.private/api/example", authorization: "Bearer leaf-private-token" },
118 ]);
119 expect((yield* request(valid)).status).toBe(401);
120 expect(
121 (yield* request(yield* proof("https://hub.example/api/example", "wrong-prefix"))).status,
122 ).toBe(401);
123 yield* auth.revokeSession(token.sessionId);
124 expect((yield* request(yield* proof(publicUrl, "revoked"))).status).toBe(401);
125 expect(requests).toHaveLength(1);
126 }).pipe(Effect.provide(NodeServices.layer)),
127);