| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { |
| 3 | AuthSessionId, |
| 4 | AuthTokenExchangeGrantType, |
| 5 | AuthEnvironmentBootstrapTokenType, |
| 6 | AuthAccessTokenType, |
| 7 | AuthAccessTokenResult, |
| 8 | EnvironmentAuthenticatedAuth, |
| 9 | EnvironmentHttpApi, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { expect, it } from "@effect/vitest"; |
| 12 | import * as Context from "effect/Context"; |
| 13 | import * as Crypto from "effect/Crypto"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Redacted from "effect/Redacted"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Tracer from "effect/Tracer"; |
| 19 | import { HttpServerRequest } from "effect/http"; |
| 20 | import * as Etag from "effect/http/Etag"; |
| 21 | import * as HttpPlatform from "effect/http/HttpPlatform"; |
| 22 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 23 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 24 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 25 | |
| 26 | import * as ServerConfig from "../config.ts"; |
| 27 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 28 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 29 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 30 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 31 | import * as AuthHttp from "./http.ts"; |
| 32 | |
| 33 | const 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 | |
| 41 | const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough"; |
| 42 | class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} |
| 43 | |
| 44 | const 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 | |
| 57 | const layerEnvironmentAuth = EnvironmentAuth.layer.pipe( |
| 58 | Layer.provide(SqlitePersistence.layerMemory), |
| 59 | Layer.provide(ServerSecretStore.layer), |
| 60 | Layer.provide(ServerEnvironment.layerIdentity), |
| 61 | Layer.provide(layerConfig), |
| 62 | ); |
| 63 | const 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 | |
| 77 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 78 | const decodeAccessToken = Schema.decodeUnknownSync(AuthAccessTokenResult); |
| 79 | const 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 | |
| 86 | it.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 | |
| 166 | it.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 | |
| 232 | it.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 | ); |