authorization.ts 2.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
  1. import { ServerAuth } from "../auth"
  2. import { UnauthorizedError } from "@kirincode-ai/protocol/errors"
  3. import { Authorization } from "@kirincode-ai/protocol/middleware/authorization"
  4. export { Authorization } from "@kirincode-ai/protocol/middleware/authorization"
  5. import { hasPtyConnectTicketURL } from "@kirincode-ai/protocol/groups/pty"
  6. import { Effect, Encoding, Layer, Redacted } from "effect"
  7. import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
  8. const AUTH_TOKEN_QUERY = "auth_token"
  9. const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
  10. function emptyCredential() {
  11. return { username: "", password: Redacted.make("") }
  12. }
  13. function decodeCredential(input: string) {
  14. return Effect.fromResult(Encoding.decodeBase64String(input)).pipe(
  15. Effect.match({
  16. onFailure: emptyCredential,
  17. onSuccess: (header) => {
  18. const separator = header.indexOf(":")
  19. if (separator === -1) return emptyCredential()
  20. return { username: header.slice(0, separator), password: Redacted.make(header.slice(separator + 1)) }
  21. },
  22. }),
  23. )
  24. }
  25. function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
  26. const url = new URL(request.url, "http://localhost")
  27. const token = url.searchParams.get(AUTH_TOKEN_QUERY)
  28. if (token) return decodeCredential(token)
  29. const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
  30. if (match) return decodeCredential(match[1])
  31. return Effect.succeed(emptyCredential())
  32. }
  33. export const authorizationLayer = Layer.effect(
  34. Authorization,
  35. Effect.gen(function* () {
  36. const config = yield* ServerAuth.Config
  37. if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
  38. return Authorization.of((effect) =>
  39. Effect.gen(function* () {
  40. const request = yield* HttpServerRequest.HttpServerRequest
  41. // Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
  42. // credential checks here; the connect handler consumes and validates the ticket.
  43. if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect
  44. const credential = yield* credentialFromRequest(request)
  45. if (ServerAuth.authorized(credential, config)) return yield* effect
  46. yield* HttpEffect.appendPreResponseHandler((_request, response) =>
  47. Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
  48. )
  49. return yield* new UnauthorizedError({ message: "Authentication required" })
  50. }),
  51. )
  52. }),
  53. )