@@ -4,3 +4,4 @@ target
|
||||
node_modules
|
||||
.shadow-cljs
|
||||
resources/public/js
|
||||
.env
|
||||
|
||||
@@ -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 <BASE_URL>/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
|
||||
@@ -5,3 +5,5 @@ node_modules
|
||||
# Compiled UI (script/ui-build)
|
||||
resources/public/js
|
||||
.nrepl-port
|
||||
# Local secrets (copy .env-template to .env)
|
||||
.env
|
||||
|
||||
@@ -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 `<BASE_URL>/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
|
||||
<group>/routes.clj Each route group (health, math, ...) owns its route subtree
|
||||
<group>/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.<group>.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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "================================")
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -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; <BASE_URL>/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})))))
|
||||
@@ -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"}))))))})
|
||||
@@ -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)}}]])
|
||||
+46
-9
@@ -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.<group>.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
|
||||
|
||||
@@ -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))))
|
||||
@@ -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)))
|
||||
@@ -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)}}])
|
||||
@@ -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")))
|
||||
@@ -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 "<html>not json</html>"}))))))))
|
||||
|
||||
(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)))))
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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")))
|
||||
@@ -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"))))
|
||||
Reference in New Issue
Block a user