| 1 | /** |
| 2 | * Who may call what. Native `HttpApiMiddleware` on the application's API, |
| 3 | * git's and ours: REST, the wire, the raw reads, and the GitHub facade. |
| 4 | * A user owns the repositories under their own name; anyone may read a |
| 5 | * public one. |
| 6 | */ |
| 7 | import { RuntimeContext } from "alchemy"; |
| 8 | import * as Git from "alchemy/Git"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware"; |
| 13 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 14 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 15 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 16 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 17 | import * as HttpApiSecurity from "effect/http-api/HttpApiSecurity"; |
| 18 | import { Auth, Session, Unauthorized } from "./auth.ts"; |
| 19 | |
| 20 | /** A 401 that makes `git` ask for credentials. */ |
| 21 | const unauthorized = HttpServerResponse.jsonUnsafe( |
| 22 | { _tag: "Unauthorized" }, |
| 23 | { status: 401, headers: { "www-authenticate": 'Basic realm="git"' } }, |
| 24 | ); |
| 25 | |
| 26 | /** |
| 27 | * An API key in the password field of the remote names a user; otherwise |
| 28 | * the session cookie does; otherwise the request is anonymous. A user may |
| 29 | * do anything under their own owner name and use the routes that have no |
| 30 | * owner (create, list, import); anyone may read a public repository. |
| 31 | */ |
| 32 | export class Authentication extends HttpApiMiddleware.Service< |
| 33 | Authentication, |
| 34 | { provides: Session } |
| 35 | >()("app/Authentication", { error: Unauthorized }) { |
| 36 | static readonly layer = Layer.effect( |
| 37 | Authentication, |
| 38 | Effect.gen(function* () { |
| 39 | const auth = yield* Auth; |
| 40 | const registry = yield* Git.RegistryStore; |
| 41 | |
| 42 | const resolve = Effect.gen(function* () { |
| 43 | const { password } = yield* HttpApiBuilder.securityDecode( |
| 44 | HttpApiSecurity.basic, |
| 45 | ); |
| 46 | const key = Redacted.value(password); |
| 47 | if (key !== "") { |
| 48 | const verified = yield* auth.api |
| 49 | .verifyApiKey({ body: { key } }) |
| 50 | .pipe( |
| 51 | Effect.catchTag("BetterAuthApiError", () => |
| 52 | Effect.succeed({ valid: false as const, key: null }), |
| 53 | ), |
| 54 | ); |
| 55 | return verified.valid && verified.key |
| 56 | ? { id: verified.key.referenceId } |
| 57 | : undefined; |
| 58 | } |
| 59 | const session = yield* auth |
| 60 | .getSession() |
| 61 | .pipe( |
| 62 | Effect.catchTag("BetterAuthApiError", () => Effect.succeed(null)), |
| 63 | ); |
| 64 | return session |
| 65 | ? { id: session.user.id, name: session.user.name } |
| 66 | : undefined; |
| 67 | }); |
| 68 | |
| 69 | /** |
| 70 | * Anonymous may read one public repository. A repository that does |
| 71 | * not exist is the route's 404, so a 401 never confirms a private one. |
| 72 | */ |
| 73 | const publicRead = Effect.gen(function* () { |
| 74 | const params = yield* HttpRouter.params; |
| 75 | const owner = params.owner?.toLowerCase(); |
| 76 | const name = params.repo?.toLowerCase().replace(/\.git$/, ""); |
| 77 | if (owner === undefined || name === undefined) return false; |
| 78 | const entry = yield* registry |
| 79 | .resolve(owner, name) |
| 80 | .pipe(Effect.catchTag("StoreError", () => Effect.succeed(undefined))); |
| 81 | return entry === undefined || entry.public; |
| 82 | }); |
| 83 | |
| 84 | return (httpEffect) => |
| 85 | Effect.gen(function* () { |
| 86 | const user = yield* resolve; |
| 87 | const { owner } = yield* HttpRouter.params; |
| 88 | const own = |
| 89 | owner === undefined || |
| 90 | owner.toLowerCase() === user?.id.toLowerCase(); |
| 91 | if (user !== undefined && own) { |
| 92 | return yield* Effect.provideService(httpEffect, Session, { user }); |
| 93 | } |
| 94 | const request = yield* HttpServerRequest; |
| 95 | if (Git.isRead(request) && (yield* publicRead)) { |
| 96 | return yield* Effect.provideService(httpEffect, Session, { |
| 97 | user: user ?? null, |
| 98 | }); |
| 99 | } |
| 100 | return unauthorized; |
| 101 | }).pipe(Effect.provide(RuntimeContext.phantom)); |
| 102 | }), |
| 103 | ); |
| 104 | } |