From d9499594bfa3c6f69a521b86a6aed54c3044e1ed Mon Sep 17 00:00:00 2001 From: ajet Date: Sat, 19 Sep 2026 07:21:19 -0900 Subject: [PATCH] add auth + remote nrepl --- .dockerignore | 1 + .env-template | 33 ++++++ .gitignore | 2 + README.md | 50 ++++++++- deps.edn | 7 +- integration-test/README.md | 7 ++ integration-test/docker-compose.yml | 7 ++ integration-test/integration_test.bb | 35 ++++++ script/server | 9 +- src/tbd/auth/core.clj | 130 ++++++++++++++++++++++ src/tbd/auth/middleware.clj | 46 ++++++++ src/tbd/auth/routes.clj | 63 +++++++++++ src/tbd/core.clj | 55 ++++++++-- src/tbd/http.clj | 20 ++++ src/tbd/nrepl/core.clj | 45 ++++++++ src/tbd/nrepl/routes.clj | 24 ++++ test/tbd/auth/core_test.clj | 111 +++++++++++++++++++ test/tbd/auth/routes_test.clj | 158 +++++++++++++++++++++++++++ test/tbd/math/routes_test.clj | 11 ++ test/tbd/nrepl/core_test.clj | 40 +++++++ test/tbd/nrepl/routes_test.clj | 92 ++++++++++++++++ 21 files changed, 932 insertions(+), 14 deletions(-) create mode 100644 .env-template create mode 100644 src/tbd/auth/core.clj create mode 100644 src/tbd/auth/middleware.clj create mode 100644 src/tbd/auth/routes.clj create mode 100644 src/tbd/http.clj create mode 100644 src/tbd/nrepl/core.clj create mode 100644 src/tbd/nrepl/routes.clj create mode 100644 test/tbd/auth/core_test.clj create mode 100644 test/tbd/auth/routes_test.clj create mode 100644 test/tbd/nrepl/core_test.clj create mode 100644 test/tbd/nrepl/routes_test.clj diff --git a/.dockerignore b/.dockerignore index 6af5f1c..cce76ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ target node_modules .shadow-cljs resources/public/js +.env diff --git a/.env-template b/.env-template new file mode 100644 index 0000000..b665290 --- /dev/null +++ b/.env-template @@ -0,0 +1,33 @@ +# Copy to .env and fill in: cp .env-template .env +# script/server loads .env automatically; with Docker pass it via `docker run --env-file .env` +# (so keep values unquoted). .env is git-ignored and excluded from the image; never commit it. + +# --- Server --------------------------------------------------------------- +# PORT=3000 + +# --- Gitea OAuth login ------------------------------------------------------ +# Register an OAuth2 application in Gitea (Settings > Applications) as a confidential +# client, with the redirect URI /auth/callback +GITEA_URL=https://gitea.example.com +GITEA_CLIENT_ID= +GITEA_CLIENT_SECRET= + +# Public URL of this app, as the browser reaches it. Use https in production: that also +# marks the session cookie Secure. +BASE_URL=http://localhost:3000 + +# Signs and encrypts session cookies. Generate with: openssl rand -hex 32 +# Unset = a random key per start, so everyone is logged out on every restart. +SESSION_SECRET= + +# --- Admin API -------------------------------------------------------------- +# Comma-separated Gitea usernames allowed to use the admin API (POST /nrepl). +# Empty = nobody can. +ADMIN_USERS= + +# --- nREPL debug server (POST /nrepl) ----------------------------------------- +# nREPL has no authentication and evaluates arbitrary code, so it listens on loopback +# only by default. Changing NREPL_BIND to 0.0.0.0 exposes it to the network: only do that +# behind a firewall or an SSH tunnel. +# NREPL_PORT=7888 +# NREPL_BIND=127.0.0.1 diff --git a/.gitignore b/.gitignore index ce43924..846f5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules # Compiled UI (script/ui-build) resources/public/js .nrepl-port +# Local secrets (copy .env-template to .env) +.env diff --git a/README.md b/README.md index eea086e..0ee09ce 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,56 @@ docker run --rm -p 3000:3000 tbd | `GET` | `/api/add?x=2&y=3` | Adds two numbers, returns EDN: `{:result 5}` (400 if either is missing or not a number) | | `GET` | `/api-docs` | Swagger UI | | `GET` | `/swagger.json` | OpenAPI/Swagger spec | +| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) | +| `GET` | `/auth/callback` | OAuth redirect target: completes the login, sets the session cookie, redirects to `/` | +| `POST` | `/auth/logout` | Ends the session | +| `GET` | `/auth/me` | The logged-in user, `{:login "alice" :id 7 :admin? true}` (401 if not logged in) | +| `POST` | `/nrepl` | **Admin only.** Body `{"enabled": true}` (JSON or EDN) starts the nREPL debug server, `false` stops it; returns `{:enabled true :bind "127.0.0.1" :port 7888}` | ```bash curl localhost:3000/api/ping # pong curl 'localhost:3000/api/add?x=2&y=3' # {:result 5} ``` +## Authentication and the admin API + +Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance. +Anyone with a Gitea account can log in, but routes marked `:admin? true` (currently +`POST /nrepl`) also require the user to be on the `ADMIN_USERS` allowlist: **401** without a +login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin. + +1. In Gitea (Settings > Applications) create an OAuth2 application, a confidential client, with + the redirect URI `/auth/callback`. +2. `cp .env-template .env` and fill it in. `script/server` loads `.env`; for Docker use + `docker run --env-file .env`. `.env` is git-ignored and kept out of the image. + +| Variable | Purpose | +|----------|---------| +| `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` | The Gitea OAuth2 application. Without all three (and `BASE_URL`), `/auth/login` answers 503 | +| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure` | +| `ADMIN_USERS` | Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API | +| `SESSION_SECRET` | Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart | +| `NREPL_PORT`, `NREPL_BIND` | Where the debug nREPL listens (default `7888` on `127.0.0.1`) | + +Sessions live in an encrypted, signed, `HttpOnly`, `SameSite=Lax` cookie and expire after 8 hours +(enforced server-side). The cookie is stateless, so `POST /auth/logout` clears it in the browser but +can't revoke a copy someone already captured; it just expires on its own. + +### The nREPL endpoint + +```bash +# after logging in in a browser, copy the `tbd-session` cookie value: +curl -XPOST localhost:3000/nrepl -H 'Cookie: tbd-session=...' \ + -H 'Content-Type: application/json' -d '{"enabled": true}' +clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.2.0"}}}' -M -m nrepl.cmdline --connect --host 127.0.0.1 --port 7888 +``` + +nREPL has no authentication and evaluates arbitrary code, so enabling it is effectively remote code +execution for whoever can reach the port. It therefore binds to loopback by default; reach it through +an SSH tunnel (or `docker exec`) rather than setting `NREPL_BIND=0.0.0.0`. Each enable/disable is +logged with the admin's Gitea username. It stays on until an admin disables it (or the server +restarts), so switch it off when you're done. + ## UI `ui/` is a [re-frame](https://github.com/day8/re-frame) app (ClojureScript, React 19 via @@ -109,7 +153,10 @@ src/tbd/ core.clj Router, Ring app, and server startup /routes.clj Each route group (health, math, ...) owns its route subtree /core.clj Business logic for the group, with no HTTP concerns + auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes + nrepl/ On-demand nREPL server (core) and the admin-only POST /nrepl route ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs +.env-template Settings the server reads from the environment; copy to .env (git-ignored) resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored) test/ Unit tests integration-test/ Dockerized integration tests and coverage harness @@ -117,4 +164,5 @@ script/ Bash entry points (server, ui-build, test, coverage, repl, ``` To add a new group, create `tbd..routes` with a `routes` fn that returns a reitit route -vector, then add it to the router in `src/tbd/core.clj`. +vector, then add it to the router in `src/tbd/core.clj`. To make a route admin-only, put +`:admin? true` in its route data. diff --git a/deps.edn b/deps.edn index 29c4bbf..50eaa58 100644 --- a/deps.edn +++ b/deps.edn @@ -3,13 +3,14 @@ {org.clojure/clojure {:mvn/version "1.12.0"} ring/ring-jetty-adapter {:mvn/version "1.12.2"} ring/ring-defaults {:mvn/version "0.4.0"} - metosin/reitit {:mvn/version "0.7.2"}} + metosin/reitit {:mvn/version "0.7.2"} + ;; Runtime dep: POST /nrepl starts an nREPL server on demand (see tbd.nrepl.core). + nrepl/nrepl {:mvn/version "1.2.0"}} :aliases {:dev {:extra-deps - {nrepl/nrepl {:mvn/version "1.2.0"} - cider/cider-nrepl {:mvn/version "0.48.0"} + {cider/cider-nrepl {:mvn/version "0.48.0"} org.babashka/http-client {:mvn/version "0.4.25"}}} :test {:extra-paths ["test"] diff --git a/integration-test/README.md b/integration-test/README.md index f724294..def7439 100644 --- a/integration-test/README.md +++ b/integration-test/README.md @@ -61,6 +61,13 @@ bb integration-test/integration_test.bb http://localhost:3000 --watch --interval | 8 | `GET /api-docs` | 200, `text/html` | | 9 | `GET /api/add?x=2&y=3` | 200, EDN `{:result 5}` | | 10 | `GET /api/add` with bad params | 400 for a missing or non-numeric param | +| 11 | `GET /auth/login` | 302 to Gitea's authorize URL with the client id and a state; `HttpOnly` cookie | +| 12 | `GET /auth/callback` with no login in progress | 400 | +| 13 | `GET /auth/me` without a session | 401 | +| 14 | `POST /nrepl` without a session | 401, even with an invalid body | + +The app container gets dummy Gitea settings from `docker-compose.yml`, so tests 11-14 cover the +unauthenticated surface only; completing a login needs a real Gitea (the unit tests stub one). Test 4 needs the compiled UI, which the Docker image includes (`script/integration-test` builds it). Against a locally running server, run `script/ui-build` first. diff --git a/integration-test/docker-compose.yml b/integration-test/docker-compose.yml index 2fd5235..4193ea9 100644 --- a/integration-test/docker-compose.yml +++ b/integration-test/docker-compose.yml @@ -10,6 +10,13 @@ services: - apparmor:unconfined environment: - PORT=${TBD_PORT:-3000} + # Dummy OAuth settings so /auth/login can be exercised; there's no Gitea to complete a login. + - BASE_URL=http://tbd-app:${TBD_PORT:-3000} + - GITEA_URL=http://gitea.invalid + - GITEA_CLIENT_ID=integration-client + - GITEA_CLIENT_SECRET=integration-secret + - ADMIN_USERS=integration-admin + - SESSION_SECRET=integration-session-secret # No container healthcheck: healthchecks run via `docker exec`, which hits the # same LXC/AppArmor runc bug (opencontainers/runc#5007) as image builds and # always fails here. integration_test.bb polls the server until it's ready. diff --git a/integration-test/integration_test.bb b/integration-test/integration_test.bb index 701d0d3..c0b6388 100755 --- a/integration-test/integration_test.bb +++ b/integration-test/integration_test.bb @@ -18,6 +18,17 @@ (defn head* [base-url] (http/head base-url {:throw false})) +;; The login redirect points at Gitea; we want to see it, not follow it. +(def no-redirect-client (http/client {:follow-redirects :never})) + +(defn get-no-redirect* [url] + (http/get url {:throw false :client no-redirect-client})) + +(defn post-json* [url body] + (http/post url {:throw false + :headers {"Content-Type" "application/json"} + :body body})) + (defn wait-for-server [{:keys [base-url max-retries retry-delay-ms]}] (println (str "=== Waiting for server at " base-url " ===")) (loop [i 1] @@ -122,6 +133,30 @@ (assert-eq "Non-numeric x" 400 (:status (get* (str base-url "/api/add?x=abc&y=3")))) (println) + (println "Test 11: GET /auth/login redirects to Gitea's authorize page") + (let [resp (get-no-redirect* (str base-url "/auth/login")) + location (get (:headers resp) "location" "")] + (assert-eq "HTTP status" 302 (:status resp)) + (assert-contains "Location" "/login/oauth/authorize?" location) + (assert-contains "Location has the client id" "client_id=integration-client" location) + (assert-contains "Location has a state" "state=" location) + (assert-contains "Session cookie is HttpOnly" "HttpOnly" (str (get (:headers resp) "set-cookie" "")))) + (println) + + (println "Test 12: GET /auth/callback with no login in progress returns 400") + (assert-eq "HTTP status" 400 (:status (get-no-redirect* (str base-url "/auth/callback?code=x&state=y")))) + (println) + + (println "Test 13: GET /auth/me without a session returns 401") + (assert-eq "HTTP status" 401 (:status (get* (str base-url "/auth/me")))) + (println) + + (println "Test 14: POST /nrepl without a session returns 401 and starts nothing") + (assert-eq "Valid body" 401 (:status (post-json* (str base-url "/nrepl") "{\"enabled\":true}"))) + (assert-eq "Invalid body (auth is checked first)" 401 (:status (post-json* (str base-url "/nrepl") "nope"))) + (assert-eq "A query-string admin flag is no bypass" 401 (:status (post-json* (str base-url "/nrepl?admin=true") "{\"enabled\":true}"))) + (println) + (println "================================") (println (format " Results: %d passed, %d failed" @pass @fail)) (println "================================") diff --git a/script/server b/script/server index 416b0d2..9a19e4c 100755 --- a/script/server +++ b/script/server @@ -1,9 +1,16 @@ #!/usr/bin/env bash # Start the server. Usage: script/server [port] -# The port defaults to $PORT, then 3000. +# The port defaults to $PORT, then 3000. Settings in .env (see .env-template) are loaded first. set -euo pipefail cd "$(dirname "$0")/.." +# Load local settings (Gitea OAuth, admin users, ...) from .env; see .env-template. +if [[ -f .env ]]; then + set -a + source .env + set +a +fi + port=${1:-${PORT:-3000}} if [[ ! $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then echo "usage: script/server [port] (port must be 1-65535, got '$port')" >&2 diff --git a/src/tbd/auth/core.clj b/src/tbd/auth/core.clj new file mode 100644 index 0000000..4f432b6 --- /dev/null +++ b/src/tbd/auth/core.clj @@ -0,0 +1,130 @@ +(ns tbd.auth.core + "Gitea OAuth2 (authorization-code flow) and the admin allowlist. No Ring here; the + routes and middleware own that." + (:require [clojure.string :as str] + [muuntaja.core :as m]) + (:import (java.net URI URLEncoder) + (java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers HttpRequest$BodyPublishers) + (java.nio.charset StandardCharsets) + (java.security MessageDigest SecureRandom) + (java.time Duration) + (java.util Base64))) + +(def ^:private http-client + (delay (-> (HttpClient/newBuilder) (.connectTimeout (Duration/ofSeconds 5)) .build))) + +(defn java-http + "Default `:http` fn: {:method :get|:post, :url, :headers, :body} -> {:status, :body}. + Configs can swap it out, which is how the tests stub Gitea." + [{:keys [method url headers body]}] + (let [builder (-> (HttpRequest/newBuilder (URI/create url)) (.timeout (Duration/ofSeconds 10)))] + (doseq [[k v] headers] (.header builder k v)) + (case method + :get (.GET builder) + :post (.POST builder (HttpRequest$BodyPublishers/ofString body))) + (let [resp (.send ^HttpClient @http-client (.build builder) (HttpResponse$BodyHandlers/ofString))] + {:status (.statusCode resp) :body (.body resp)}))) + +(defn- env-val [env k] + (let [v (get env k)] + (when-not (str/blank? v) (str/trim v)))) + +(defn- strip-trailing-slashes [s] + (some-> s (str/replace #"/+$" ""))) + +(defn env-config + "Auth config from environment variables: + GITEA_URL, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET the Gitea OAuth2 application + BASE_URL public URL of this app; /auth/callback is the redirect URI to register in Gitea + ADMIN_USERS comma-separated Gitea usernames allowed to use the admin API (none by default) + SESSION_SECRET signs/encrypts session cookies (random per start when unset, so logins don't survive restarts)" + ([] (env-config (System/getenv))) + ([env] + (let [base-url (strip-trailing-slashes (env-val env "BASE_URL"))] + {:gitea-url (strip-trailing-slashes (env-val env "GITEA_URL")) + :client-id (env-val env "GITEA_CLIENT_ID") + :client-secret (env-val env "GITEA_CLIENT_SECRET") + :redirect-uri (some-> base-url (str "/auth/callback")) + :secure-cookies? (boolean (some-> base-url (str/starts-with? "https://"))) + :admin-users (->> (str/split (or (env-val env "ADMIN_USERS") "") #",") + (map str/trim) + (remove str/blank?) + (map str/lower-case) + set) + :session-secret (env-val env "SESSION_SECRET") + :http java-http}))) + +(defn configured? + "True when everything needed to run the OAuth flow is set." + [config] + (every? config [:gitea-url :client-id :client-secret :redirect-uri])) + +(defn admin? + "Gitea usernames are case-insensitive, so the allowlist is compared lower-cased." + [config login] + (contains? (:admin-users config) (str/lower-case login))) + +(defn session-key + "Cookie stores need a 16-byte key; derive it from the secret, or make a throwaway one." + [secret] + (if secret + (byte-array (take 16 (.digest (MessageDigest/getInstance "SHA-256") + (.getBytes ^String secret StandardCharsets/UTF_8)))) + (let [k (byte-array 16)] + (.nextBytes (SecureRandom.) k) + k))) + +(defn new-state + "Random OAuth `state`, tying the callback to the browser that started the login." + [] + (let [bytes (byte-array 32)] + (.nextBytes (SecureRandom.) bytes) + (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) bytes))) + +(defn state-matches? [expected actual] + (and (string? expected) + (string? actual) + (MessageDigest/isEqual (.getBytes ^String expected StandardCharsets/UTF_8) + (.getBytes ^String actual StandardCharsets/UTF_8)))) + +(defn- form-encode [params] + (str/join "&" (map (fn [[k v]] + (str (URLEncoder/encode (name k) StandardCharsets/UTF_8) "=" + (URLEncoder/encode (str v) StandardCharsets/UTF_8))) + params))) + +(defn authorize-url [{:keys [gitea-url client-id redirect-uri]} state] + (str gitea-url "/login/oauth/authorize?" + (form-encode {:client_id client-id + :redirect_uri redirect-uri + :response_type "code" + :state state}))) + +(defn- parse-json [body] + (m/decode "application/json" body)) + +(defn exchange-code + "Trades the authorization code for an access token; nil if Gitea refuses it." + [{:keys [gitea-url client-id client-secret redirect-uri http]} code] + (let [{:keys [status body]} (http {:method :post + :url (str gitea-url "/login/oauth/access_token") + :headers {"Content-Type" "application/x-www-form-urlencoded" + "Accept" "application/json"} + :body (form-encode {:grant_type "authorization_code" + :client_id client-id + :client_secret client-secret + :redirect_uri redirect-uri + :code code})})] + (when (= 200 status) + (:access_token (parse-json body))))) + +(defn fetch-user + "The Gitea user behind an access token as {:login :id}; nil if Gitea refuses the token." + [{:keys [gitea-url http]} token] + (let [{:keys [status body]} (http {:method :get + :url (str gitea-url "/api/v1/user") + :headers {"Authorization" (str "Bearer " token) + "Accept" "application/json"}})] + (when (= 200 status) + (let [{:keys [login id]} (parse-json body)] + (when login {:login login :id id}))))) diff --git a/src/tbd/auth/middleware.clj b/src/tbd/auth/middleware.clj new file mode 100644 index 0000000..84df19e --- /dev/null +++ b/src/tbd/auth/middleware.clj @@ -0,0 +1,46 @@ +(ns tbd.auth.middleware + (:require [ring.middleware.session :refer [wrap-session]] + [ring.middleware.session.cookie :refer [cookie-store]] + [tbd.auth.core :as auth] + [tbd.http :refer [edn-response]])) + +(def session-ttl-ms (* 8 60 60 1000)) + +(defn session-middleware + "Cookie-backed sessions (encrypted and signed, so nothing is stored server-side). Lax, not + Strict: the OAuth callback is a cross-site redirect from Gitea and must still carry the + cookie that holds the login `state`." + [config] + (let [store (cookie-store {:key (auth/session-key (:session-secret config))})] + (fn [handler] + (wrap-session handler {:store store + :cookie-name "tbd-session" + :cookie-attrs {:http-only true + :same-site :lax + :secure (:secure-cookies? config) + :max-age (quot session-ttl-ms 1000)}})))) + +(defn current-user + "The logged-in user from the session, or nil when there is none or it has expired. The + cookie is only a bearer of `:expires-at`, so the server enforces the lifetime here." + ([req] (current-user req (System/currentTimeMillis))) + ([req now] + (let [{:keys [expires-at] :as user} (get-in req [:session :user])] + (when (and user expires-at (< now expires-at)) + user)))) + +(defn admin-middleware + "Reitit middleware for routes whose data has `:admin? true`: 401 without a login, 403 when + the user isn't on the ADMIN_USERS allowlist. Passes the user on as `:user`. Mount it ahead + of the parsing/coercion middleware so unauthenticated callers never reach them." + [config] + {:name ::admin + :compile (fn [route-data _opts] + (when (:admin? route-data) + (fn [handler] + (fn [req] + (if-let [user (current-user req)] + (if (auth/admin? config (:login user)) + (handler (assoc req :user user)) + (edn-response 403 {:error "admin access required"})) + (edn-response 401 {:error "login required"}))))))}) diff --git a/src/tbd/auth/routes.clj b/src/tbd/auth/routes.clj new file mode 100644 index 0000000..eb6f530 --- /dev/null +++ b/src/tbd/auth/routes.clj @@ -0,0 +1,63 @@ +(ns tbd.auth.routes + (:require [ring.util.response :as response] + [tbd.auth.core :as auth] + [tbd.auth.middleware :as mw] + [tbd.http :refer [edn-response]])) + +(def ^:private not-configured + (edn-response 503 {:error "OAuth is not configured (set GITEA_URL, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, BASE_URL)"})) + +(defn- login-handler [config] + (fn [{:keys [session]}] + (if-not (auth/configured? config) + not-configured + (let [state (auth/new-state)] + (-> (response/redirect (auth/authorize-url config state)) + (assoc :session (assoc session :oauth-state state))))))) + +(defn- callback-handler [config] + (fn [{:keys [session parameters]}] + (let [{:keys [code state error]} (:query parameters)] + (cond + (not (auth/configured? config)) not-configured + (not (auth/state-matches? (:oauth-state session) state)) (edn-response 400 {:error "invalid OAuth state"}) + error (edn-response 403 {:error "login was denied"}) + (nil? code) (edn-response 400 {:error "missing authorization code"}) + :else + (try + (let [user (some->> (auth/exchange-code config code) (auth/fetch-user config))] + (if user + ;; A fresh session (not the old one plus a user) so nothing from before login carries over. + (-> (response/redirect "/") + (assoc :session {:user (assoc user :expires-at (+ (System/currentTimeMillis) + mw/session-ttl-ms))})) + (edn-response 401 {:error "login failed"}))) + (catch Exception e + (println "[tbd] Gitea login failed:" (.getMessage e)) + (edn-response 502 {:error "could not complete login with Gitea"}))))))) + +(defn- me-handler [config] + (fn [req] + (if-let [{:keys [login id]} (mw/current-user req)] + (edn-response 200 {:login login :id id :admin? (auth/admin? config login)}) + (edn-response 401 {:error "login required"})))) + +(defn routes [config] + ["/auth" + ["/login" {:get {:tags ["auth"] + :summary "Start the Gitea OAuth login (redirects to Gitea)" + :handler (login-handler config)}}] + ["/callback" {:get {:tags ["auth"] + :summary "OAuth redirect target; completes the login and redirects to /" + :parameters {:query [:map + [:code {:optional true} :string] + [:state {:optional true} :string] + [:error {:optional true} :string]]} + :handler (callback-handler config)}}] + ["/logout" {:post {:tags ["auth"] + :summary "End the session" + :handler (fn [_req] + (assoc (edn-response 200 {:logged-out true}) :session nil))}}] + ["/me" {:get {:tags ["auth"] + :summary "The logged-in user (401 if not logged in)" + :handler (me-handler config)}}]]) diff --git a/src/tbd/core.clj b/src/tbd/core.clj index 944b577..ec3d718 100644 --- a/src/tbd/core.clj +++ b/src/tbd/core.clj @@ -13,8 +13,14 @@ [reitit.ring.middleware.parameters :as parameters] [muuntaja.core :as m] [ring.util.response :as response] + [tbd.auth.core :as auth] + [tbd.auth.middleware :as auth-mw] + [tbd.auth.routes :as auth-routes] [tbd.health.routes :as health] - [tbd.math.routes :as math])) + [tbd.http :as http] + [tbd.math.routes :as math] + [tbd.nrepl.core :as nrepl] + [tbd.nrepl.routes :as nrepl-routes])) (defn- swagger-handler "reitit emits `\"definitions\": null` when no route declares a schema, which @@ -41,14 +47,22 @@ (-> (response/resource-response "public/index.html") (response/content-type "text/html; charset=utf-8"))) +(defn- decode-error-handler + "A request body that isn't valid for its Content-Type -> 400 in the same EDN shape." + [_e _req] + {:status 400 + :headers {"Content-Type" "application/edn; charset=utf-8"} + :body (pr-str {:error "malformed request body"})}) + (def ^:private exception-middleware (exception/create-exception-middleware (assoc exception/default-handlers - :reitit.coercion/request-coercion coercion-error-handler))) + :reitit.coercion/request-coercion coercion-error-handler + :muuntaja/decode decode-error-handler))) ;; Each logical group (health, users, orders, ...) owns a `tbd..routes` ;; namespace that returns its own route subtree; they're concatenated here. -(def router +(defn make-router [{:keys [auth nrepl]}] (ring/router [["/swagger.json" {:get {:no-doc true @@ -57,23 +71,40 @@ ["/" {:get {:no-doc true :handler index-handler}}] (health/routes) - (math/routes)] + (math/routes) + (auth-routes/routes auth) + (nrepl-routes/routes nrepl)] {:data {:muuntaja m/instance :coercion malli/coercion - :middleware [parameters/parameters-middleware - muuntaja-middleware/format-middleware + ;; Admin auth goes first so unauthenticated callers to `:admin? true` routes + ;; are turned away before any body parsing or coercion runs. The muuntaja + ;; middleware is split around the exception middleware (rather than the combined + ;; format-middleware) so a malformed request body is caught and answered with a 400. + :middleware [(auth-mw/admin-middleware auth) + parameters/parameters-middleware + muuntaja-middleware/format-negotiate-middleware + muuntaja-middleware/format-response-middleware exception-middleware + muuntaja-middleware/format-request-middleware coercion/coerce-request-middleware]}})) -(def app +(defn make-app [{:keys [auth] :as config}] (-> (ring/ring-handler - router + (make-router config) (ring/routes (swagger-ui/create-swagger-ui-handler {:path "/api-docs"}) ;; Compiled UI (script/ui-build writes resources/public/js). (ring/create-resource-handler {:path "/js" :root "public/js"}) (ring/create-default-handler))) - wrap-head)) + wrap-head + http/wrap-browser-viewable-edn + ((auth-mw/session-middleware auth)))) + +(def config + {:auth (auth/env-config) + :nrepl (nrepl/env-config)}) + +(def app (make-app config)) (defn add-shutdown-hook [server] (.addShutdownHook @@ -84,6 +115,12 @@ (let [port (Integer/parseInt (str (or (System/getenv "PORT") (get opts :port 3000))))] (println (format "[tbd] starting server on port %d..." port)) + (when-not (auth/configured? (:auth config)) + (println "[tbd] WARNING: Gitea OAuth is not configured; login and the admin API are unavailable")) + (when (empty? (get-in config [:auth :admin-users])) + (println "[tbd] WARNING: ADMIN_USERS is empty; nobody can use the admin API")) + (when-not (get-in config [:auth :session-secret]) + (println "[tbd] WARNING: SESSION_SECRET is not set; logins won't survive a restart")) (try (let [server (jetty/run-jetty (wrap-defaults app api-defaults) {:port port diff --git a/src/tbd/http.clj b/src/tbd/http.clj new file mode 100644 index 0000000..aa827b9 --- /dev/null +++ b/src/tbd/http.clj @@ -0,0 +1,20 @@ +(ns tbd.http) + +(defn edn-response [status body] + {:status status + :headers {"Content-Type" "application/edn; charset=utf-8"} + :body (pr-str body)}) + +(defn wrap-browser-viewable-edn + "Browsers download `application/edn` instead of showing it. When the request comes from a + browser navigation (Accept includes text/html), relabel EDN responses as text/plain so they + render inline. Programmatic clients (fetch, curl, ...) still get `application/edn`." + [handler] + (fn [req] + (let [resp (handler req) + accept (get-in req [:headers "accept"] "") + content-type (get-in resp [:headers "Content-Type"] "")] + (if (and (re-find #"text/html" accept) + (.startsWith ^String content-type "application/edn")) + (assoc-in resp [:headers "Content-Type"] "text/plain; charset=utf-8") + resp)))) diff --git a/src/tbd/nrepl/core.clj b/src/tbd/nrepl/core.clj new file mode 100644 index 0000000..d3fbf71 --- /dev/null +++ b/src/tbd/nrepl/core.clj @@ -0,0 +1,45 @@ +(ns tbd.nrepl.core + "The on-demand nREPL debug server. No HTTP here." + (:require [clojure.string :as str] + [nrepl.server :as nrepl])) + +(def default-port 7888) + +;; nREPL has no authentication and evaluates arbitrary code, so it only listens on loopback +;; unless the operator explicitly opens it up. +(def default-bind "127.0.0.1") + +(defn env-config + "NREPL_PORT (default 7888) and NREPL_BIND (default 127.0.0.1)." + ([] (env-config (System/getenv))) + ([env] + (let [port (get env "NREPL_PORT") + bind (get env "NREPL_BIND")] + {:port (if (str/blank? port) default-port (parse-long (str/trim port))) + :bind (if (str/blank? bind) default-bind (str/trim bind))}))) + +(defonce ^:private state (atom nil)) + +(defn status [] + (if-let [{:keys [port bind]} @state] + {:enabled true :bind bind :port port} + {:enabled false})) + +(defn enable! + "Starts the server if it isn't running (a no-op otherwise); returns the status. Port 0 + picks a free port, and the status reports the one actually bound." + [{:keys [port bind]}] + (locking state + (when-not @state + (let [server (nrepl/start-server :port port :bind bind)] + (reset! state {:server server :bind bind :port (:port server)}))) + (status))) + +(defn disable! + "Stops the server if it's running; returns the status." + [] + (locking state + (when-let [{:keys [server]} @state] + (nrepl/stop-server server) + (reset! state nil)) + (status))) diff --git a/src/tbd/nrepl/routes.clj b/src/tbd/nrepl/routes.clj new file mode 100644 index 0000000..d0cdf6b --- /dev/null +++ b/src/tbd/nrepl/routes.clj @@ -0,0 +1,24 @@ +(ns tbd.nrepl.routes + (:require [tbd.http :refer [edn-response]] + [tbd.nrepl.core :as nrepl])) + +(defn- toggle-handler [config] + (fn [{{{:keys [enabled]} :body} :parameters {:keys [login]} :user}] + (try + (let [status (if enabled (nrepl/enable! config) (nrepl/disable!))] + (println (format "[tbd] nREPL %s by %s%s" + (if enabled "enabled" "disabled") + login + (if enabled (format " on %s:%d" (:bind status) (:port status)) ""))) + (edn-response 200 status)) + (catch Exception e + (println "[tbd] nREPL toggle failed:" (.getMessage e)) + (edn-response 500 {:error (str "could not change nREPL state: " (.getMessage e))}))))) + +(defn routes [config] + ["/nrepl" + {:post {:tags ["admin"] + :summary "Enable or disable the nREPL debug server (admin only)" + :admin? true + :parameters {:body [:map [:enabled {:description "true starts the server, false stops it"} :boolean]]} + :handler (toggle-handler config)}}]) diff --git a/test/tbd/auth/core_test.clj b/test/tbd/auth/core_test.clj new file mode 100644 index 0000000..d984311 --- /dev/null +++ b/test/tbd/auth/core_test.clj @@ -0,0 +1,111 @@ +(ns tbd.auth.core-test + (:require [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [tbd.auth.core :as auth])) + +(def full-env + {"GITEA_URL" "https://git.example.com/" + "GITEA_CLIENT_ID" "cid" + "GITEA_CLIENT_SECRET" "sec" + "BASE_URL" "https://tbd.example.com/" + "ADMIN_USERS" " Alice, bob ,," + "SESSION_SECRET" "s3cret"}) + +(deftest env-config-test + (testing "a complete environment" + (let [config (auth/env-config full-env)] + (is (auth/configured? config)) + (is (= "https://git.example.com" (:gitea-url config)) "trailing slash is dropped") + (is (= "https://tbd.example.com/auth/callback" (:redirect-uri config))) + (is (= #{"alice" "bob"} (:admin-users config)) "trimmed, lower-cased, blanks dropped") + (is (true? (:secure-cookies? config)) "https base URL => Secure cookies") + (is (= "s3cret" (:session-secret config))))) + (testing "plain http base URL does not mark cookies Secure" + (is (false? (:secure-cookies? (auth/env-config (assoc full-env "BASE_URL" "http://localhost:3000")))))) + (testing "an empty environment is unconfigured and has no admins" + (let [config (auth/env-config {})] + (is (not (auth/configured? config))) + (is (= #{} (:admin-users config))) + (is (nil? (:session-secret config))))) + (testing "blank values count as unset" + (is (not (auth/configured? (auth/env-config (assoc full-env "GITEA_CLIENT_SECRET" " ")))))) + (testing "each of the four settings is required" + (doseq [k ["GITEA_URL" "GITEA_CLIENT_ID" "GITEA_CLIENT_SECRET" "BASE_URL"]] + (is (not (auth/configured? (auth/env-config (dissoc full-env k)))) k)))) + +(deftest admin-test + (let [config (auth/env-config full-env)] + (is (auth/admin? config "alice")) + (is (auth/admin? config "ALICE") "Gitea usernames are case-insensitive") + (is (not (auth/admin? config "mallory"))) + (is (not (auth/admin? (auth/env-config {}) "alice")) "an empty allowlist admits nobody"))) + +(deftest session-key-test + (is (= 16 (count (auth/session-key "s3cret")))) + (is (= (seq (auth/session-key "s3cret")) (seq (auth/session-key "s3cret")))) + (is (not= (seq (auth/session-key "a")) (seq (auth/session-key "b")))) + (testing "without a secret each call yields a fresh random key" + (is (= 16 (count (auth/session-key nil)))) + (is (not= (seq (auth/session-key nil)) (seq (auth/session-key nil)))))) + +(deftest state-test + (let [a (auth/new-state)] + (is (re-matches #"[A-Za-z0-9_-]{43}" a)) + (is (not= a (auth/new-state)))) + (is (auth/state-matches? "abc" "abc")) + (is (not (auth/state-matches? "abc" "abd"))) + (is (not (auth/state-matches? "abc" "abcd"))) + (testing "a missing state never matches, even against another missing one" + (is (not (auth/state-matches? nil nil))) + (is (not (auth/state-matches? "abc" nil))) + (is (not (auth/state-matches? nil "abc"))))) + +(deftest authorize-url-test + (let [url (auth/authorize-url (auth/env-config full-env) "st ate")] + (is (str/starts-with? url "https://git.example.com/login/oauth/authorize?")) + (is (str/includes? url "client_id=cid")) + (is (str/includes? url "response_type=code")) + (is (str/includes? url "redirect_uri=https%3A%2F%2Ftbd.example.com%2Fauth%2Fcallback")) + (is (str/includes? url "state=st+ate") "values are form-encoded"))) + +(defn- recording-http + "An :http stub that records requests and answers with `responses` in order." + [calls & responses] + (let [responses (atom responses)] + (fn [req] + (swap! calls conj req) + (let [[r & more] @responses] + (reset! responses more) + (if (instance? Throwable r) (throw r) r))))) + +(deftest exchange-code-test + (let [calls (atom []) + config (assoc (auth/env-config full-env) + :http (recording-http calls + {:status 200 :body "{\"access_token\":\"tok\",\"token_type\":\"bearer\"}"} + {:status 400 :body "{\"error\":\"invalid_grant\"}"}))] + (is (= "tok" (auth/exchange-code config "the-code"))) + (let [{:keys [method url headers body]} (first @calls)] + (is (= :post method)) + (is (= "https://git.example.com/login/oauth/access_token" url)) + (is (= "application/json" (get headers "Accept"))) + (is (= "application/x-www-form-urlencoded" (get headers "Content-Type"))) + (doseq [part ["grant_type=authorization_code" "client_id=cid" "client_secret=sec" "code=the-code" + "redirect_uri=https%3A%2F%2Ftbd.example.com%2Fauth%2Fcallback"]] + (is (str/includes? body part) part))) + (is (nil? (auth/exchange-code config "used-code")) "a refused code yields nil"))) + +(deftest fetch-user-test + (let [calls (atom []) + config (assoc (auth/env-config full-env) + :http (recording-http calls + {:status 200 :body "{\"login\":\"alice\",\"id\":7,\"email\":\"a@x\"}"} + {:status 401 :body "{}"} + {:status 200 :body "{\"id\":7}"}))] + (is (= {:login "alice" :id 7} (auth/fetch-user config "tok"))) + (let [{:keys [method url headers]} (first @calls)] + (is (= :get method)) + (is (= "https://git.example.com/api/v1/user" url)) + (is (= "Bearer tok" (get headers "Authorization")))) + (is (nil? (auth/fetch-user config "bad")) "a refused token yields nil") + (is (nil? (auth/fetch-user config "odd")) "a response without a login yields nil"))) diff --git a/test/tbd/auth/routes_test.clj b/test/tbd/auth/routes_test.clj new file mode 100644 index 0000000..96d1413 --- /dev/null +++ b/test/tbd/auth/routes_test.clj @@ -0,0 +1,158 @@ +(ns tbd.auth.routes-test + (:require [clojure.edn :as edn] + [clojure.string :as str] + [clojure.test :refer [deftest is testing]] + [tbd.auth.middleware :as mw] + [tbd.core :as core])) + +(defn gitea-stub + "Stands in for Gitea: any code is exchanged for a token that resolves to `login`." + [login] + (fn [{:keys [url]}] + (cond + (str/ends-with? url "/login/oauth/access_token") {:status 200 :body "{\"access_token\":\"tok\"}"} + (str/ends-with? url "/api/v1/user") {:status 200 :body (format "{\"login\":\"%s\",\"id\":7}" login)}))) + +(defn make-app [http & {:as overrides}] + (core/make-app {:auth (merge {:gitea-url "https://gitea.test" + :client-id "cid" + :client-secret "sec" + :redirect-uri "http://tbd.test/auth/callback" + :admin-users #{"alice"} + :session-secret "test-secret" + :secure-cookies? false + :http http} + overrides) + :nrepl {:port 0 :bind "127.0.0.1"}})) + +(defn request [app method uri & {:keys [cookie query]}] + (let [resp (app (cond-> {:request-method method :uri uri :query-string query :headers {}} + cookie (assoc-in [:headers "cookie"] cookie)))] + (cond-> resp + (and (string? (:body resp)) + (str/includes? (get-in resp [:headers "Content-Type"] "") "edn")) + (update :body edn/read-string)))) + +(defn set-cookie + "The `name=value` pair from a response's Set-Cookie header." + [resp] + (let [h (get-in resp [:headers "Set-Cookie"])] + (first (str/split (first (if (string? h) [h] h)) #";")))) + +(defn location-state [resp] + (second (re-find #"[?&]state=([^&]+)" (get-in resp [:headers "Location"])))) + +(defn login! + "Runs the whole login dance and returns the session cookie." + [app] + (let [start (request app :get "/auth/login") + done (request app :get "/auth/callback" + :cookie (set-cookie start) + :query (str "code=abc&state=" (location-state start)))] + (set-cookie done))) + +(deftest login-redirect-test + (let [resp (request (make-app (gitea-stub "alice")) :get "/auth/login") + location (get-in resp [:headers "Location"])] + (is (= 302 (:status resp))) + (is (str/starts-with? location "https://gitea.test/login/oauth/authorize?")) + (is (str/includes? location "client_id=cid")) + (is (some? (location-state resp))) + (let [cookie (get-in resp [:headers "Set-Cookie"]) + cookie (first (if (string? cookie) [cookie] cookie))] + (is (str/starts-with? cookie "tbd-session=")) + (is (re-find #"(?i)HttpOnly" cookie)) + (is (re-find #"(?i)SameSite=Lax" cookie)) + (is (not (re-find #"(?i);\s*Secure" cookie)) "http base URL, so no Secure flag")))) + +(deftest secure-cookie-test + (let [resp (request (make-app (gitea-stub "alice") :secure-cookies? true) :get "/auth/login") + cookie (first (let [h (get-in resp [:headers "Set-Cookie"])] (if (string? h) [h] h)))] + (is (re-find #"(?i);\s*Secure" cookie)))) + +(deftest unconfigured-test + (let [app (make-app (gitea-stub "alice") :gitea-url nil)] + (is (= 503 (:status (request app :get "/auth/login")))) + (is (= 503 (:status (request app :get "/auth/callback" :query "code=x&state=y")))))) + +(deftest callback-test + (let [app (make-app (gitea-stub "Alice")) + start (request app :get "/auth/login") + cookie (set-cookie start) + state (location-state start)] + (testing "a successful login redirects home and sets a session" + (let [resp (request app :get "/auth/callback" :cookie cookie :query (str "code=abc&state=" state))] + (is (= 302 (:status resp))) + (is (= "/" (get-in resp [:headers "Location"]))) + (is (some? (set-cookie resp))))) + (testing "no session cookie (the callback wasn't preceded by a login here)" + (is (= 400 (:status (request app :get "/auth/callback" :query (str "code=abc&state=" state)))))) + (testing "wrong or missing state" + (is (= 400 (:status (request app :get "/auth/callback" :cookie cookie :query "code=abc&state=nope")))) + (is (= 400 (:status (request app :get "/auth/callback" :cookie cookie :query "code=abc"))))) + (testing "the user denied access at Gitea" + (is (= 403 (:status (request app :get "/auth/callback" :cookie cookie + :query (str "error=access_denied&state=" state)))))) + (testing "missing code" + (is (= 400 (:status (request app :get "/auth/callback" :cookie cookie :query (str "state=" state)))))))) + +(deftest callback-gitea-failure-test + (let [login-with (fn [http] + (let [app (make-app http) + start (request app :get "/auth/login") + resp (atom nil)] + ;; the 502 path logs; keep it out of the test output + (with-out-str + (reset! resp (request app :get "/auth/callback" :cookie (set-cookie start) + :query (str "code=abc&state=" (location-state start))))) + @resp))] + (testing "Gitea rejects the code" + (let [resp (login-with (fn [_] {:status 400 :body "{\"error\":\"invalid_grant\"}"}))] + (is (= 401 (:status resp))) + (is (nil? (get-in resp [:headers "Set-Cookie"])) "no session is created"))) + (testing "Gitea rejects the token" + (is (= 401 (:status (login-with (fn [{:keys [url]}] + (if (str/ends-with? url "/access_token") + {:status 200 :body "{\"access_token\":\"tok\"}"} + {:status 401 :body "{}"}))))))) + (testing "Gitea is unreachable or answers garbage" + (is (= 502 (:status (login-with (fn [_] (throw (java.net.ConnectException. "refused"))))))) + (is (= 502 (:status (login-with (fn [_] {:status 200 :body "not json"})))))))) + +(deftest me-and-logout-test + (let [app (make-app (gitea-stub "Alice")) + cookie (login! app)] + (testing "anonymous" + (is (= 401 (:status (request app :get "/auth/me"))))) + (testing "logged in" + (let [resp (request app :get "/auth/me" :cookie cookie)] + (is (= 200 (:status resp))) + (is (= {:login "Alice" :id 7 :admin? true} (:body resp))))) + (testing "logged in as a non-admin" + (let [app (make-app (gitea-stub "bob"))] + (is (= {:login "bob" :id 7 :admin? false} + (:body (request app :get "/auth/me" :cookie (login! app))))))) + (testing "logout replaces the session with an empty one" + (let [resp (request app :post "/auth/logout" :cookie cookie)] + (is (= 200 (:status resp))) + (is (= 401 (:status (request app :get "/auth/me" :cookie (set-cookie resp))))))))) + +(deftest forged-session-test + (let [app (make-app (gitea-stub "alice")) + cookie (login! app)] + (testing "a cookie tampered with is ignored" + (let [tampered (str (subs cookie 0 (- (count cookie) 4)) "AAAA")] + (is (= 401 (:status (request app :get "/auth/me" :cookie tampered)))))) + (testing "a cookie minted under another SESSION_SECRET is ignored" + (let [other (make-app (gitea-stub "alice") :session-secret "other-secret")] + (is (= 401 (:status (request other :get "/auth/me" :cookie cookie)))))) + (testing "garbage" + (is (= 401 (:status (request app :get "/auth/me" :cookie "tbd-session=not-a-real-cookie"))))))) + +(deftest current-user-expiry-test + (let [req {:session {:user {:login "alice" :expires-at 1000}}}] + (is (= {:login "alice" :expires-at 1000} (mw/current-user req 999))) + (is (nil? (mw/current-user req 1000)) "expired exactly at expires-at") + (is (nil? (mw/current-user req 5000))) + (is (nil? (mw/current-user {:session {:user {:login "alice"}}} 0)) "a user with no expiry is not trusted") + (is (nil? (mw/current-user {} 0))))) diff --git a/test/tbd/math/routes_test.clj b/test/tbd/math/routes_test.clj index a9df9ab..ba6af95 100644 --- a/test/tbd/math/routes_test.clj +++ b/test/tbd/math/routes_test.clj @@ -26,6 +26,17 @@ (is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"]))) (is (contains? (:body resp) :error)))))) +(deftest add-route-browser-test + (testing "browser navigations get an inline-renderable type so Chrome doesn't download the EDN" + (let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3" + :headers {"accept" "text/html,application/xhtml+xml,*/*;q=0.8"}})] + (is (= "text/plain; charset=utf-8" (get-in resp [:headers "Content-Type"]))) + (is (= {:result 5} (edn/read-string (:body resp)))))) + (testing "programmatic clients still get application/edn" + (let [resp (core/app {:request-method :get :uri "/api/add" :query-string "x=2&y=3" + :headers {"accept" "*/*"}})] + (is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"])))))) + (deftest add-route-swagger-test (let [spec (-> (core/app {:request-method :get :uri "/swagger.json" :headers {"accept" "application/json"}}) diff --git a/test/tbd/nrepl/core_test.clj b/test/tbd/nrepl/core_test.clj new file mode 100644 index 0000000..2743505 --- /dev/null +++ b/test/tbd/nrepl/core_test.clj @@ -0,0 +1,40 @@ +(ns tbd.nrepl.core-test + (:require [clojure.test :refer [deftest is testing use-fixtures]] + [tbd.nrepl.core :as nrepl]) + (:import (java.net ConnectException InetSocketAddress Socket ServerSocket))) + +(use-fixtures :each (fn [t] (try (t) (finally (nrepl/disable!))))) + +(defn listening? [port] + (try (with-open [s (Socket.)] + (.connect s (InetSocketAddress. "127.0.0.1" (int port)) 500) + true) + (catch java.io.IOException _ false))) + +(deftest env-config-test + (is (= {:port 7888 :bind "127.0.0.1"} (nrepl/env-config {}))) + (is (= {:port 7888 :bind "127.0.0.1"} (nrepl/env-config {"NREPL_PORT" " " "NREPL_BIND" ""}))) + (is (= {:port 9000 :bind "0.0.0.0"} (nrepl/env-config {"NREPL_PORT" "9000" "NREPL_BIND" "0.0.0.0"})))) + +(deftest enable-disable-test + (is (= {:enabled false} (nrepl/status))) + (let [{:keys [enabled bind port] :as status} (nrepl/enable! {:port 0 :bind "127.0.0.1"})] + (is (true? enabled)) + (is (= "127.0.0.1" bind)) + (is (pos? port) "port 0 reports the port actually bound") + (is (= status (nrepl/status))) + (is (listening? port)) + (testing "enabling again is a no-op" + (is (= status (nrepl/enable! {:port 0 :bind "127.0.0.1"})))) + (testing "disabling closes the port" + (is (= {:enabled false} (nrepl/disable!))) + (is (= {:enabled false} (nrepl/status))) + (is (not (listening? port)))) + (testing "disabling again is a no-op" + (is (= {:enabled false} (nrepl/disable!)))))) + +(deftest enable-failure-test + (with-open [taken (ServerSocket. 0 1 (java.net.InetAddress/getByName "127.0.0.1"))] + (is (thrown? java.net.BindException + (nrepl/enable! {:port (.getLocalPort taken) :bind "127.0.0.1"}))) + (is (= {:enabled false} (nrepl/status)) "a failed start leaves nREPL disabled"))) diff --git a/test/tbd/nrepl/routes_test.clj b/test/tbd/nrepl/routes_test.clj new file mode 100644 index 0000000..54b4ef1 --- /dev/null +++ b/test/tbd/nrepl/routes_test.clj @@ -0,0 +1,92 @@ +(ns tbd.nrepl.routes-test + (:require [clojure.edn :as edn] + [clojure.test :refer [deftest is testing use-fixtures]] + [muuntaja.core :as m] + [tbd.auth.routes-test :as t] + [tbd.nrepl.core :as nrepl]) + (:import (java.io ByteArrayInputStream) + (java.net InetAddress ServerSocket))) + +(use-fixtures :each (fn [f] (try (f) (finally (nrepl/disable!))))) + +(defn post-nrepl + "POST /nrepl with `body` (a string, or nil for none), as JSON unless `content-type` says otherwise. + The handler logs each toggle; that's kept out of the test output." + ([app cookie body] (post-nrepl app cookie body "application/json")) + ([app cookie body content-type] + (let [resp (atom nil)] + (with-out-str + (reset! resp (app (cond-> {:request-method :post :uri "/nrepl" + :headers (cond-> {"content-type" content-type} + cookie (assoc "cookie" cookie))} + body (assoc :body (ByteArrayInputStream. (.getBytes ^String body))))))) + (cond-> @resp (string? (:body @resp)) (update :body edn/read-string))))) + +(deftest anonymous-test + (let [app (t/make-app (t/gitea-stub "alice"))] + (doseq [body ["{\"enabled\":true}" "{\"enabled\":\"junk\"}" nil]] + (let [resp (post-nrepl app nil body)] + (is (= 401 (:status resp)) (str "body: " body)))) + (is (= {:enabled false} (nrepl/status)) "nothing was started"))) + +(deftest non-admin-test + (let [app (t/make-app (t/gitea-stub "mallory")) + cookie (t/login! app)] + (is (= 403 (:status (post-nrepl app cookie "{\"enabled\":true}")))) + (is (= {:enabled false} (nrepl/status)) "nothing was started"))) + +(deftest empty-allowlist-test + (let [app (t/make-app (t/gitea-stub "alice") :admin-users #{}) + cookie (t/login! app)] + (is (= 403 (:status (post-nrepl app cookie "{\"enabled\":true}")))))) + +(deftest admin-test + (let [app (t/make-app (t/gitea-stub "Alice")) + cookie (t/login! app)] + (testing "enable" + (let [resp (post-nrepl app cookie "{\"enabled\":true}")] + (is (= 200 (:status resp))) + (is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"]))) + (is (= {:enabled true :bind "127.0.0.1"} (dissoc (:body resp) :port))) + (is (pos? (get-in resp [:body :port]))) + (is (= (:body resp) (nrepl/status))))) + (testing "disable" + (let [resp (post-nrepl app cookie "{\"enabled\":false}")] + (is (= 200 (:status resp))) + (is (= {:enabled false} (:body resp))) + (is (= {:enabled false} (nrepl/status))))) + (testing "EDN bodies work too" + (let [resp (post-nrepl app cookie "{:enabled true}" "application/edn")] + (is (= 200 (:status resp))) + (is (true? (get-in resp [:body :enabled]))))))) + +(deftest invalid-body-test + (let [app (t/make-app (t/gitea-stub "alice")) + cookie (t/login! app)] + (doseq [body [nil "{}" "{\"enabled\":\"maybe\"}" "{\"enabled\":null}" "not json"]] + (let [resp (post-nrepl app cookie body)] + (is (= 400 (:status resp)) (str "body: " body)) + (is (contains? (:body resp) :error) (str "body: " body)))) + (is (= {:enabled false} (nrepl/status))))) + +(deftest bind-failure-test + (with-open [taken (ServerSocket. 0 1 (InetAddress/getByName "127.0.0.1"))] + (let [app (tbd.core/make-app + {:auth {:gitea-url "https://gitea.test" :client-id "cid" :client-secret "sec" + :redirect-uri "http://tbd.test/auth/callback" :admin-users #{"alice"} + :session-secret "test-secret" :http (t/gitea-stub "alice")} + :nrepl {:port (.getLocalPort taken) :bind "127.0.0.1"}}) + cookie (t/login! app) + resp (post-nrepl app cookie "{\"enabled\":true}")] + (is (= 500 (:status resp))) + (is (re-find #"could not change nREPL state" (get-in resp [:body :error]))) + (is (= {:enabled false} (nrepl/status)))))) + +(deftest swagger-test + (let [app (t/make-app (t/gitea-stub "alice")) + spec (-> (app {:request-method :get :uri "/swagger.json" :headers {"accept" "application/json"}}) + :body + (->> (m/decode "application/json"))) + paths (into {} (map (fn [[k v]] [(str (namespace k) "/" (name k)) v])) (:paths spec))] + (is (contains? (get-in paths ["/nrepl"]) :post) "documented") + (is (contains? paths "/auth/login"))))