httpapi-authorization.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import { NodeHttpServer } from "@effect/platform-node"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Layer, Option, Schema } from "effect"
  4. import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
  5. import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
  6. import { ServerAuth } from "../../src/server/auth"
  7. import {
  8. Authorization,
  9. authorizationLayer,
  10. ServerAuthorization,
  11. serverAuthorizationLayer,
  12. } from "../../src/server/routes/instance/httpapi/middleware/authorization"
  13. import { testEffect } from "../lib/effect"
  14. const Api = HttpApi.make("test-authorization").add(
  15. HttpApiGroup.make("test")
  16. .add(
  17. HttpApiEndpoint.get("probe", "/probe", {
  18. success: Schema.String,
  19. }),
  20. HttpApiEndpoint.get("missing", "/missing", {
  21. success: Schema.String,
  22. error: HttpApiError.NotFound,
  23. }),
  24. )
  25. .middleware(Authorization),
  26. )
  27. const ServerApi = HttpApi.make("test-server-authorization").add(
  28. HttpApiGroup.make("test.v2")
  29. .add(
  30. HttpApiEndpoint.get("probe", "/api/probe", {
  31. success: Schema.String,
  32. }),
  33. )
  34. .middleware(ServerAuthorization),
  35. )
  36. const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
  37. handlers
  38. .handle("probe", () => Effect.succeed("ok"))
  39. .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
  40. )
  41. const serverHandlers = HttpApiBuilder.group(ServerApi, "test.v2", (handlers) =>
  42. handlers.handle("probe", () => Effect.succeed("ok")),
  43. )
  44. const apiLayer = HttpRouter.serve(
  45. HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
  46. { disableListenLog: true, disableLogger: true },
  47. ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
  48. const v2ApiLayer = HttpRouter.serve(
  49. HttpApiBuilder.layer(ServerApi).pipe(Layer.provide(serverHandlers), Layer.provide(serverAuthorizationLayer)),
  50. { disableListenLog: true, disableLogger: true },
  51. ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
  52. const noAuthLayer = ServerAuth.Config.configLayer({ password: Option.none(), username: "kirincode" })
  53. const secretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "kirincode" })
  54. const kitSecretLayer = ServerAuth.Config.configLayer({ password: Option.some("secret"), username: "kit" })
  55. const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
  56. const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
  57. const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
  58. const itV2Secret = testEffect(v2ApiLayer.pipe(Layer.provide(secretLayer)))
  59. const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
  60. const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
  61. const getProbe = (headers?: Record<string, string>) =>
  62. HttpClientRequest.get("/probe").pipe(
  63. headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
  64. HttpClient.execute,
  65. )
  66. describe("HttpApi authorization middleware", () => {
  67. it.live("allows requests when server password is not configured", () =>
  68. Effect.gen(function* () {
  69. const response = yield* getProbe()
  70. expect(response.status).toBe(200)
  71. expect(yield* response.json).toBe("ok")
  72. }),
  73. )
  74. itSecret.live("requires configured password for basic auth", () =>
  75. Effect.gen(function* () {
  76. const [missing, badPassword, good] = yield* Effect.all(
  77. [
  78. getProbe(),
  79. getProbe({ authorization: basic("kirincode", "wrong") }),
  80. getProbe({ authorization: basic("kirincode", "secret") }),
  81. ],
  82. { concurrency: "unbounded" },
  83. )
  84. expect(missing.status).toBe(401)
  85. expect(missing.headers["www-authenticate"] ?? "").toContain("Basic")
  86. expect(badPassword.status).toBe(401)
  87. expect(badPassword.headers["www-authenticate"] ?? "").toContain("Basic")
  88. expect(good.status).toBe(200)
  89. }),
  90. )
  91. itKitSecret.live("respects configured basic auth username", () =>
  92. Effect.gen(function* () {
  93. const [defaultUser, configuredUser] = yield* Effect.all(
  94. [getProbe({ authorization: basic("kirincode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
  95. { concurrency: "unbounded" },
  96. )
  97. expect(defaultUser.status).toBe(401)
  98. expect(configuredUser.status).toBe(200)
  99. }),
  100. )
  101. itSecret.live("accepts auth token query credentials", () =>
  102. Effect.gen(function* () {
  103. const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("kirincode", "secret"))}`)
  104. expect(response.status).toBe(200)
  105. }),
  106. )
  107. itSecret.live("prefers auth token query credentials over basic auth", () =>
  108. Effect.gen(function* () {
  109. const response = yield* HttpClientRequest.get(
  110. `/probe?auth_token=${encodeURIComponent(token("kirincode", "secret"))}`,
  111. ).pipe(HttpClientRequest.setHeader("authorization", basic("kirincode", "wrong")), HttpClient.execute)
  112. expect(response.status).toBe(200)
  113. }),
  114. )
  115. itSecret.live("preserves handler errors when basic auth succeeds", () =>
  116. Effect.gen(function* () {
  117. const response = yield* HttpClientRequest.get("/missing").pipe(
  118. HttpClientRequest.setHeader("authorization", basic("kirincode", "secret")),
  119. HttpClient.execute,
  120. )
  121. expect(response.status).toBe(404)
  122. }),
  123. )
  124. itSecret.live("preserves handler errors when auth token query succeeds", () =>
  125. Effect.gen(function* () {
  126. const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("kirincode", "secret"))}`)
  127. expect(response.status).toBe(404)
  128. }),
  129. )
  130. itSecret.live("rejects malformed auth token query credentials", () =>
  131. Effect.gen(function* () {
  132. const response = yield* HttpClient.get("/probe?auth_token=not-base64")
  133. expect(response.status).toBe(401)
  134. }),
  135. )
  136. itV2Secret.live("returns bodyful v2 unauthorized errors", () =>
  137. Effect.gen(function* () {
  138. const response = yield* HttpClient.get("/api/probe")
  139. const body = yield* response.json
  140. expect(response.status).toBe(401)
  141. expect(response.headers["www-authenticate"] ?? "").toContain("Basic")
  142. expect(body).toEqual({ _tag: "UnauthorizedError", message: "Authentication required" })
  143. }),
  144. )
  145. })