| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { AuthOrchestrationReadScope, EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { computeDpopAccessTokenHash, computeDpopJwkThumbprint } from "@t3tools/shared/dpop"; |
| 5 | import * as Clock from "effect/Clock"; |
| 6 | import * as Context from "effect/Context"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import { HttpClient, HttpClientResponse, HttpRouter } from "effect/http"; |
| 10 | import { exportJWK, generateKeyPair, SignJWT } from "jose"; |
| 11 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 12 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 13 | import * as SessionStore from "../auth/SessionStore.ts"; |
| 14 | import * as ServerConfig from "../config.ts"; |
| 15 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 16 | import * as ServerEnvironment from "./ServerEnvironment.ts"; |
| 17 | import * as Hub from "./Hub.ts"; |
| 18 | import * as HubHttp from "./HubHttp.ts"; |
| 19 | import * as HubProxy from "./HubProxy.ts"; |
| 20 | |
| 21 | it.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 | ); |