add validation and unified schemas
CI / Test + coverage (push) Successful in 1m3s

This commit is contained in:
2026-09-19 12:48:41 -09:00
parent 64d1a35da2
commit aa4f8ea86a
30 changed files with 2027 additions and 77 deletions
+7
View File
@@ -60,6 +60,13 @@
(is (not (auth/state-matches? "abc" nil)))
(is (not (auth/state-matches? nil "abc")))))
(deftest safe-return-to-test
(doseq [ok ["/" "/oauth/authorize?client_id=a&state=b%2Fc" "/a/b#frag"]]
(is (= ok (auth/safe-return-to ok)) ok))
(doseq [bad [nil 42 "" "oauth/authorize" "//evil.example.com" "/\\evil.example.com" "/\\/evil.example.com"
"https://evil.example.com" "javascript:alert(1)" "/a b" "/a\nb" (str "/" (apply str (repeat 2000 "a")))]]
(is (nil? (auth/safe-return-to bad)) (pr-str bad))))
(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?"))
+20 -4
View File
@@ -15,10 +15,12 @@
(str/ends-with? url "/api/v1/user") {:status 200 :body (format "{\"login\":\"%s\",\"id\":%d}" login id)}))))
(defn make-app
"The app against a Gitea stub. `:todo` (a todo config) is passed through; other overrides go into the auth config."
[http & {:keys [todo] :as overrides}]
"The app against a Gitea stub. `:todo` and `:oauth` (their configs) are passed through; other overrides go into the auth config."
[http & {:keys [todo oauth] :as overrides}]
(core/make-app {:todo todo
:auth (merge {:gitea-url "https://gitea.test"
:oauth oauth
:auth (merge {:base-url "http://tbd.test"
:gitea-url "https://gitea.test"
:client-id "cid"
:client-secret "sec"
:redirect-uri "http://tbd.test/auth/callback"
@@ -26,7 +28,7 @@
:session-secret "test-secret"
:secure-cookies? false
:http http}
(dissoc overrides :todo))
(dissoc overrides :todo :oauth))
:nrepl {:port 0 :bind "127.0.0.1"}}))
(defn request [app method uri & {:keys [cookie query]}]
@@ -66,6 +68,20 @@
(is (re-find #"(?i)SameSite=Lax" cookie))
(is (not (re-find #"(?i);\s*Secure" cookie)) "http base URL, so no Secure flag"))))
(deftest return-to-test
(let [login-then-callback (fn [return-to]
(let [app (make-app (gitea-stub "alice"))
start (request app :get "/auth/login" :query (when return-to (str "return_to=" return-to)))]
(request app :get "/auth/callback" :cookie (set-cookie start)
:query (str "code=abc&state=" (location-state start)))))]
(testing "after logging in the browser goes back to where it came from"
(is (= "/oauth/authorize?a=b" (get-in (login-then-callback "%2Foauth%2Fauthorize%3Fa%3Db") [:headers "Location"]))))
(testing "and to / without a return_to"
(is (= "/" (get-in (login-then-callback nil) [:headers "Location"]))))
(testing "but never to another site"
(doseq [bad ["https%3A%2F%2Fevil.example.com" "%2F%2Fevil.example.com" "%2F%5Cevil.example.com" "javascript%3Aalert(1)"]]
(is (= "/" (get-in (login-then-callback bad) [:headers "Location"])) bad)))))
(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)))]
+263
View File
@@ -0,0 +1,263 @@
(ns tbd.mcp.routes-test
(:require [clojure.test :refer [deftest is testing]]
[muuntaja.core :as m]
[tbd.todo.schema :as todo-schema]
[malli.core :as malli]
[tbd.oauth.core :as oauth]
[tbd.oauth.db :as db]
[tbd.oauth.db-test :as db-test]
[tbd.oauth.routes-test :as o]
[tbd.auth.routes-test :as t])
(:import (java.io ByteArrayInputStream)))
;; The MCP handler sits behind its own transport, so these go through the real app (tbd.core)
;; and speak JSON-RPC to /mcp the way an MCP client does, with an OAuth access token.
(defn- mint-token!
"Puts a grant for `login` into the database at `path` and returns its access token, for
tests that aren't about how a client gets one (that is tbd.oauth.routes-test)."
[path login id & {:keys [expires-at] :or {expires-at (+ (System/currentTimeMillis) 60000)}}]
(let [token (oauth/random-token)]
(db/insert-grant! (db/open path) {:client-id "test-client" :user-id id :login login :scope "mcp"
:access-hash (oauth/hash-token token) :access-expires-at expires-at
:refresh-hash (oauth/hash-token (oauth/random-token))
:refresh-expires-at expires-at :created-at 1})
token))
(defn- rpc
"POST a JSON-RPC message (as data) to /mcp with `token` as the bearer token, optionally within
the MCP session `sid`. Returns the response with its JSON body decoded (nil when there is none)."
([app token message] (rpc app token message nil))
([app token message sid]
(let [body (m/encode "application/json" message)
resp (app {:request-method :post :uri "/mcp"
:scheme :http :server-name "localhost" :server-port 3000
:headers (cond-> {"content-type" "application/json"
"accept" "application/json, text/event-stream"}
token (assoc "authorization" (str "Bearer " token))
sid (assoc "mcp-session-id" sid))
:body (ByteArrayInputStream. (.readAllBytes ^java.io.InputStream body))})]
(cond-> resp
(string? (:body resp)) (update :body #(m/decode "application/json" %))))))
(defn- initialize
"Runs the MCP handshake; returns the session id."
[app token]
(let [resp (rpc app token {:jsonrpc "2.0" :id 1 :method "initialize"
:params {:protocolVersion "2025-06-18" :capabilities {}
:clientInfo {:name "test" :version "1"}}})
sid (get-in resp [:headers "Mcp-Session-Id"])]
(is (= 200 (:status resp)))
(is (= {:name "tbd" :version "1.0.0"} (select-keys (get-in resp [:body :result :serverInfo]) [:name :version])))
(is (some? sid) "the server opens a session")
(is (= 202 (:status (rpc app token {:jsonrpc "2.0" :method "notifications/initialized"} sid))))
sid))
(defn- call-tool [app token sid tool arguments]
(:body (rpc app token {:jsonrpc "2.0" :id 2 :method "tools/call"
:params {:name tool :arguments arguments}}
sid)))
(defn- text [result] (get-in result [:result :content 0 :text]))
(defn- fixture
"An app on a fresh database, with the tokens of an admin (alice) and a regular user (bob)."
[]
(let [path (db-test/temp-db-path)]
{:path path
:app (t/make-app (t/gitea-stub "alice" 7) :oauth {:db-path path} :todo {:db-path (db-test/temp-db-path)})
:alice (mint-token! path "alice" 7)
:bob (mint-token! path "bob" 8)}))
(deftest list-tools-test
(let [{:keys [app alice]} (fixture)
sid (initialize app alice)
tools (get-in (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"} sid) [:body :result :tools])]
(is (= ["add" "create_todo" "delete_todo" "get_todo" "list_todos" "ping" "update_todo" "whoami"]
(sort (map :name tools))))
(let [input-schema (fn [tool] (:inputSchema (first (filter #(= tool (:name %)) tools))))]
(testing "a tool's input schema is generated from the Malli schema the HTTP API also uses"
(let [add (input-schema "add")]
(is (= ["x" "y"] (:required add)))
(is (= {:x "First addend" :y "Second addend"}
(update-vals (:properties add) :description)))
(is (= ["number" "number"] (map :type (vals (:properties add))))))
(is (= {:type "object" :required ["id"] :properties {:id {:type "integer" :description "Todo id"}}}
(input-schema "get_todo")))
(is (= ["id"] (:required (input-schema "update_todo"))) "title and done are optional")
(is (= #{"id" "title" "done"} (set (map name (keys (:properties (input-schema "update_todo"))))))))
(testing "and ones with no arguments say so"
(is (= {:type "object" :properties {}} (input-schema "list_todos")))))))
(deftest call-tool-test
(let [{:keys [app alice]} (fixture)
sid (initialize app alice)]
(testing "ping"
(is (= {:content [{:type "text" :text "pong"}] :isError false}
(:result (call-tool app alice sid "ping" {})))))
(testing "add goes through tbd.math.core"
(is (= {:content [{:type "text" :text "5"}] :isError false}
(:result (call-tool app alice sid "add" {:x 2 :y 3}))))
(is (= "3.5" (text (call-tool app alice sid "add" {:x 1.5 :y 2})))))
(testing "missing arguments are a tool error the model can read, not a failed request"
(let [result (:result (call-tool app alice sid "add" {:x 2}))]
(is (true? (:isError result)))
(is (re-find #"Missing tool param" (get-in result [:content 0 :text])))))
(testing "an unknown tool is a JSON-RPC error"
(is (contains? (call-tool app alice sid "nope" {}) :error)))))
(deftest whoami-test
(let [{:keys [app alice bob]} (fixture)]
(testing "tools know which user the token belongs to, and whether they are an admin"
(is (= "alice (Gitea user 7), admin" (text (call-tool app alice (initialize app alice) "whoami" {}))))
(is (= "bob (Gitea user 8)" (text (call-tool app bob (initialize app bob) "whoami" {})))))))
(deftest authentication-test
(let [{:keys [path app alice]} (fixture)
metadata "resource_metadata=\"http://tbd.test/.well-known/oauth-protected-resource/mcp\""
initialize-message {:jsonrpc "2.0" :id 1 :method "initialize"
:params {:protocolVersion "2025-06-18" :capabilities {}
:clientInfo {:name "test" :version "1"}}}]
(testing "no token: 401 pointing the client at the OAuth metadata (that is how it finds where to log in)"
(let [resp (rpc app nil initialize-message)
challenge (get-in resp [:headers "WWW-Authenticate"])]
(is (= 401 (:status resp)))
(is (.startsWith ^String challenge "Bearer "))
(is (.contains ^String challenge metadata))
(is (not (.contains ^String challenge "error=")) "no token isn't an invalid one")
(is (nil? (get-in resp [:headers "Mcp-Session-Id"])) "no session is opened")))
(testing "the wrong kind of credentials"
(doseq [authorization ["Basic YWxpY2U6cHc=" "Bearer" "Bearer " alice "bearer"]]
(let [resp ((:app (fixture)) {:request-method :post :uri "/mcp"
:headers {"authorization" authorization}
:body (ByteArrayInputStream. (.getBytes "{}"))})]
(is (= 401 (:status resp)) authorization))))
(testing "an unknown token: 401 saying so"
(let [resp (rpc app "not-a-token" initialize-message)
challenge (get-in resp [:headers "WWW-Authenticate"])]
(is (= 401 (:status resp)))
(is (.contains ^String challenge "error=\"invalid_token\""))
(is (.contains ^String challenge metadata))))
(testing "a refresh token or a stored hash isn't an access token"
(let [{:keys [tokens]} (o/get-tokens! app)]
(is (= 401 (:status (rpc app (:refresh_token tokens) initialize-message))))
(is (= 401 (:status (rpc app (oauth/hash-token alice) initialize-message))))))
(testing "an expired token"
(is (= 401 (:status (rpc app (mint-token! path "alice" 7 :expires-at (- (System/currentTimeMillis) 1000))
initialize-message)))))
(testing "the token is checked on every request, not only when the session is opened"
(let [sid (initialize app alice)]
(is (= 401 (:status (rpc app nil {:jsonrpc "2.0" :id 2 :method "tools/list"} sid))))
(is (= 401 (:status (rpc app "not-a-token" {:jsonrpc "2.0" :id 2 :method "tools/list"} sid))))
(is (= 401 (:status (app {:request-method :delete :uri "/mcp" :headers {"mcp-session-id" sid}}))))
(is (= 200 (:status (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"} sid))))))
(testing "and the body isn't read for a caller who isn't allowed in"
(let [resp (app {:request-method :post :uri "/mcp" :headers {}
:body (proxy [java.io.InputStream] [] (read ([] (throw (ex-info "body was read" {})))))})]
(is (= 401 (:status resp)))))))
(deftest admin-follows-the-allowlist-test
(let [path (db-test/temp-db-path)
token (mint-token! path "alice" 7)
app-with (fn [admins] (t/make-app (t/gitea-stub "alice") :oauth {:db-path path} :admin-users admins))]
(is (= "alice (Gitea user 7), admin" (text (call-tool (app-with #{"alice"}) token (initialize (app-with #{"alice"}) token) "whoami" {}))))
(testing "it is decided per request, not when the token was issued"
(let [app (app-with #{})]
(is (= "alice (Gitea user 7)" (text (call-tool app token (initialize app token) "whoami" {}))))))))
(deftest unconfigured-test
(let [app (t/make-app (t/gitea-stub "alice") :oauth {:db-path (db-test/temp-db-path)} :gitea-url nil)
resp (rpc app "anything" {:jsonrpc "2.0" :id 1 :method "initialize"})]
(is (= 503 (:status resp)) "with no login there is no way to get a token, so /mcp isn't open instead")))
(deftest end-to-end-test
(testing "a client that does the whole OAuth dance ends up as the user who approved it"
(let [app (o/app-for (db-test/temp-db-path) "carol" 9)
{:keys [client-id tokens]} (o/get-tokens! app)
whoami (fn [access-token] (text (call-tool app access-token (initialize app access-token) "whoami" {})))]
(is (= "carol (Gitea user 9)" (whoami (:access_token tokens))))
(testing "and after a refresh, when the first token no longer works"
(let [refreshed (:body (o/call app :post "/oauth/token"
:form {"grant_type" "refresh_token" "refresh_token" (:refresh_token tokens)
"client_id" client-id}))]
(is (= "carol (Gitea user 9)" (whoami (:access_token refreshed))))
(is (= 401 (:status (rpc app (:access_token tokens) {:jsonrpc "2.0" :id 1 :method "initialize"})))))))))
(deftest session-required-test
(testing "tools can't be used before the handshake opens a session"
(let [{:keys [app alice]} (fixture)
resp (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"})]
(is (<= 400 (:status resp) 499)))))
(deftest no-server-stream-test
(testing "GET /mcp (the optional server-to-client SSE stream) is a 405, which MCP clients accept"
(let [{:keys [app alice]} (fixture)
resp (app {:request-method :get :uri "/mcp"
:headers {"accept" "text/event-stream" "authorization" (str "Bearer " alice)}})]
(is (= 405 (:status resp))))))
(deftest close-session-test
(let [{:keys [app alice]} (fixture)
sid (initialize app alice)]
(is (= 202 (:status (app {:request-method :delete :uri "/mcp"
:headers {"mcp-session-id" sid "authorization" (str "Bearer " alice)}}))))
(testing "a closed session is gone"
(is (= 404 (:status (rpc app alice {:jsonrpc "2.0" :id 2 :method "tools/list"} sid)))))))
(deftest api-unaffected-test
(testing "the MCP route doesn't disturb the rest of the API (which it shares a router with)"
(let [{:keys [app]} (fixture)]
(is (= 200 (:status (app {:request-method :get :uri "/api/ping"}))))
(is (= 200 (:status (app {:request-method :get :uri "/api/add" :query-string "x=2&y=3"})))))))
(deftest todo-tools-test
(let [{:keys [app alice bob]} (fixture)
alice-sid (initialize app alice)
bob-sid (initialize app bob)
call (fn [token sid tool arguments] (call-tool app token sid tool arguments))
as-alice (partial call alice alice-sid)
as-bob (partial call bob bob-sid)
result (fn [resp] (:result resp))
data (fn [resp] (m/decode "application/json" (text resp)))
error (fn [resp] (when (true? (get-in resp [:result :isError])) (text resp)))]
(testing "no todos yet"
(is (= [] (data (as-alice "list_todos" {})))))
(let [created (data (as-alice "create_todo" {:title " write tests "}))
id (:id created)]
(testing "create trims the title, as the API does, and returns the todo"
(is (= {:title "write tests" :done false :owner-id 7} (select-keys created [:title :done :owner-id])))
(is (integer? id)))
(testing "get and list"
(is (= created (data (as-alice "get_todo" {:id id}))))
(is (= [created] (data (as-alice "list_todos" {})))))
(testing "update changes the title and/or done"
(is (= {:title "write tests" :done true} (select-keys (data (as-alice "update_todo" {:id id :done true})) [:title :done])))
(is (= {:title "ship it" :done true} (select-keys (data (as-alice "update_todo" {:id id :title "ship it"})) [:title :done]))))
(testing "todos are private to their owner, and someone else's is reported as missing"
(is (= [] (data (as-bob "list_todos" {}))))
(doseq [[tool args] [["get_todo" {:id id}] ["update_todo" {:id id :done false}] ["delete_todo" {:id id}]]]
(is (= "todo not found" (error (as-bob tool args))) tool))
(is (= "ship it" (:title (data (as-alice "get_todo" {:id id})))) "and bob's attempts changed nothing"))
(testing "the rules of tbd.todo.core apply here too"
(is (= "title must not be blank" (error (as-alice "create_todo" {:title " "}))))
(is (= "nothing to change: give a title and/or done" (error (as-alice "update_todo" {:id id})))))
(testing "delete"
(is (= "deleted" (text (as-alice "delete_todo" {:id id}))))
(is (= "todo not found" (error (as-alice "get_todo" {:id id}))))
(is (= [] (data (as-alice "list_todos" {}))))))))
(deftest tool-arguments-are-validated-test
(let [{:keys [app alice]} (fixture)
sid (initialize app alice)
error (fn [tool arguments]
(let [resp (call-tool app alice sid tool arguments)]
(is (true? (get-in resp [:result :isError])) (str tool " " (pr-str arguments)))
(text resp)))]
(testing "arguments of the wrong type are turned away, which plumcp alone doesn't do"
(is (= "invalid arguments: {:id [\"should be an integer\"]}" (error "get_todo" {:id "abc"})))
(is (= "invalid arguments: {:title [\"should be a string\"]}" (error "create_todo" {:title 5})))
(is (= "invalid arguments: {:done [\"should be a boolean\"]}" (error "update_todo" {:id 1 :done "yes"})))
(is (= "invalid arguments: {:x [\"should be an integer\" \"should be a double\"]}" (error "add" {:x "2" :y 3}))))
(testing "the tools and the HTTP API validate against the same schemas"
(is (false? (malli/validate todo-schema/UpdateTodo {:id 1 :done "yes"})))
(is (true? (malli/validate todo-schema/UpdateTodo {:id 1 :done true}))))))
+101
View File
@@ -0,0 +1,101 @@
(ns tbd.oauth.core-test
(:require [clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[tbd.oauth.core :as oauth]))
(deftest metadata-test
(let [base "https://tbd.example.com"]
(is (= {:resource "https://tbd.example.com/mcp"
:authorization_servers [base]}
(select-keys (oauth/protected-resource-metadata base) [:resource :authorization_servers])))
(let [m (oauth/authorization-server-metadata base)]
(is (= base (:issuer m)))
(is (= "https://tbd.example.com/oauth/authorize" (:authorization_endpoint m)))
(is (= "https://tbd.example.com/oauth/token" (:token_endpoint m)))
(is (= "https://tbd.example.com/oauth/register" (:registration_endpoint m)))
(is (= ["S256"] (:code_challenge_methods_supported m)) "PKCE is mandatory, and only S256")
(is (= ["none"] (:token_endpoint_auth_methods_supported m)) "public clients only"))))
(deftest token-test
(let [a (oauth/random-token)]
(is (re-matches #"[A-Za-z0-9_-]{43}" a))
(is (not= a (oauth/random-token))))
(testing "stored form is a stable, different hex digest"
(is (= (oauth/hash-token "abc") (oauth/hash-token "abc")))
(is (re-matches #"[0-9a-f]{64}" (oauth/hash-token "abc")))
(is (not= "abc" (oauth/hash-token "abc")))
(is (not= (oauth/hash-token "abc") (oauth/hash-token "abd")))))
(deftest pkce-test
;; The example from RFC 7636 appendix B.
(let [verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"]
(is (= challenge (oauth/pkce-challenge verifier)))
(is (oauth/pkce-valid? challenge verifier))
(testing "a different verifier, or none, fails"
(is (not (oauth/pkce-valid? challenge (str/replace verifier "d" "e"))))
(is (not (oauth/pkce-valid? challenge nil))))
(testing "the challenge itself is not a verifier, and a too-short verifier is refused"
(is (not (oauth/pkce-valid? challenge challenge)))
(is (not (oauth/pkce-valid? (oauth/pkce-challenge "short") "short"))))
(is (oauth/challenge-format? challenge))
(is (not (oauth/challenge-format? "too-short")))
(is (not (oauth/challenge-format? nil)))))
(deftest valid-redirect-uri-test
(doseq [ok ["https://client.example.com/callback"
"http://localhost:8080/callback"
"http://127.0.0.1/cb"
"http://[::1]:9000/cb"
"cursor://anysphere.cursor-retrieval/oauth/callback"]]
(is (oauth/valid-redirect-uri? ok) ok))
(doseq [bad ["http://client.example.com/callback" ; plain http off the local machine
"http://localhost.evil.com/cb"
"javascript:alert(1)"
"data:text/html,hi"
"file:///etc/passwd"
"https://client.example.com/cb#frag"
"https://user:pw@client.example.com/cb"
"https:///nohost"
"/relative/path"
"not a uri"
""
nil
42
(str "https://client.example.com/" (apply str (repeat 2100 "a")))]]
(is (not (oauth/valid-redirect-uri? bad)) (pr-str bad))))
(deftest redirect-uri-matches-test
(is (oauth/redirect-uri-matches? "https://c.example.com/cb" "https://c.example.com/cb"))
(is (not (oauth/redirect-uri-matches? "https://c.example.com/cb" "https://c.example.com/cb2")))
(is (not (oauth/redirect-uri-matches? "https://c.example.com/cb" "https://c.example.com/cb/")))
(is (not (oauth/redirect-uri-matches? "https://c.example.com/cb" "https://c.example.com/cb?x=1")))
(is (not (oauth/redirect-uri-matches? "https://c.example.com/cb" nil)))
(testing "a client on the local machine picks its own port"
(is (oauth/redirect-uri-matches? "http://localhost/cb" "http://localhost:53682/cb"))
(is (oauth/redirect-uri-matches? "http://127.0.0.1:1/cb" "http://127.0.0.1:2/cb"))
(is (not (oauth/redirect-uri-matches? "http://localhost/cb" "http://localhost:53682/other")))
(is (not (oauth/redirect-uri-matches? "http://localhost/cb" "http://127.0.0.1:53682/cb")))
(is (not (oauth/redirect-uri-matches? "https://c.example.com/cb" "https://c.example.com:8443/cb")) "only loopback")))
(deftest redirect-with-test
(is (= "https://c.example.com/cb?code=a+b&state=s"
(oauth/redirect-with "https://c.example.com/cb" {:code "a b" :state "s"})))
(is (= "https://c.example.com/cb?x=1&code=c"
(oauth/redirect-with "https://c.example.com/cb?x=1" {:code "c"})))
(is (= "https://c.example.com/cb?code=c" (oauth/redirect-with "https://c.example.com/cb" {:code "c" :state nil}))
"absent values are dropped"))
(deftest scope-and-resource-test
(is (= "mcp" (oauth/parse-scope nil)))
(is (= "mcp" (oauth/parse-scope "")))
(is (= "mcp" (oauth/parse-scope " mcp ")))
(is (nil? (oauth/parse-scope "mcp admin")))
(is (nil? (oauth/parse-scope "admin")))
(let [base "https://tbd.example.com"]
(is (oauth/resource-ok? base nil) "the parameter is optional")
(is (oauth/resource-ok? base "https://tbd.example.com/mcp"))
(is (oauth/resource-ok? base "https://tbd.example.com/mcp/"))
(is (oauth/resource-ok? base ["https://tbd.example.com/mcp"]))
(is (not (oauth/resource-ok? base "https://other.example.com/mcp")))
(is (not (oauth/resource-ok? base ["https://tbd.example.com/mcp" "https://other.example.com/mcp"])))))
+90
View File
@@ -0,0 +1,90 @@
(ns tbd.oauth.db-test
(:require [clojure.java.io :as io]
[clojure.test :refer [deftest is testing]]
[tbd.oauth.db :as db])
(:import (java.nio.file Files)
(java.nio.file.attribute FileAttribute)))
(defn temp-db-path []
(str (Files/createTempDirectory "tbd-oauth" (make-array FileAttribute 0)) "/data/oauth.db"))
(defn client [id created-at]
{:id id :name "Test client" :redirect-uris ["https://c.example.com/cb" "http://localhost/cb"] :created-at created-at})
(defn grant [n]
{:client-id "c1" :user-id 7 :login "alice" :scope "mcp"
:access-hash (str "access-" n) :access-expires-at 2000
:refresh-hash (str "refresh-" n) :refresh-expires-at 9000
:created-at 1000})
(deftest env-config-test
(is (= {:db-path "data/oauth.db"} (db/env-config {})))
(is (= {:db-path "data/oauth.db"} (db/env-config {"OAUTH_DB_PATH" " "})))
(is (= {:db-path "/var/oauth.db"} (db/env-config {"OAUTH_DB_PATH" " /var/oauth.db "}))))
(deftest open-test
(testing "creates the file and its directory"
(let [path (temp-db-path)]
(db/open path)
(is (.exists (io/file path)))))
(testing "no path is an error, not a database in the working directory"
(is (thrown? clojure.lang.ExceptionInfo (db/open nil)))))
(deftest client-test
(let [ds (db/open (temp-db-path))]
(is (nil? (db/find-client ds "c1")))
(db/insert-client! ds (client "c1" 1000))
(is (= (client "c1" 1000) (db/find-client ds "c1")) "redirect URIs come back as a vector of strings")))
(deftest code-test
(let [ds (db/open (temp-db-path))
code {:hash "h1" :client-id "c1" :user-id 7 :login "alice" :redirect-uri "https://c.example.com/cb"
:code-challenge "chal" :scope "mcp" :expires-at 5000}]
(db/insert-code! ds code)
(is (= code (db/take-code! ds "h1")) "the stored row comes back")
(testing "a code can be taken once"
(is (nil? (db/take-code! ds "h1"))))
(is (nil? (db/take-code! ds "unknown")))))
(deftest grant-test
(let [ds (db/open (temp-db-path))]
(db/insert-grant! ds (grant 1))
(testing "found by access token until it expires"
(is (= "alice" (:login (db/find-grant-by-access ds "access-1" 1999))))
(is (nil? (db/find-grant-by-access ds "access-1" 2000)))
(is (nil? (db/find-grant-by-access ds "refresh-1" 1000)) "a refresh token isn't an access token"))
(testing "found by refresh token"
(is (= 7 (:user-id (db/find-grant-by-refresh ds "refresh-1"))))
(is (nil? (db/find-grant-by-refresh ds "access-1"))))
(testing "rotating replaces both tokens, and works once per refresh token"
(let [id (:id (db/find-grant-by-refresh ds "refresh-1"))
fresh {:access-hash "access-2" :access-expires-at 3000 :refresh-hash "refresh-2" :refresh-expires-at 9500}]
(is (= "access-2" (:access-hash (db/rotate-grant! ds id "refresh-1" fresh))))
(is (nil? (db/find-grant-by-access ds "access-1" 1000)))
(is (nil? (db/find-grant-by-refresh ds "refresh-1")))
(is (nil? (db/rotate-grant! ds id "refresh-1" (assoc fresh :access-hash "access-3" :refresh-hash "refresh-3")))
"the old refresh token can't rotate it again")
(is (some? (db/find-grant-by-access ds "access-2" 1000)))))
(testing "deleting revokes"
(db/delete-grant! ds (:id (db/find-grant-by-refresh ds "refresh-2")))
(is (nil? (db/find-grant-by-access ds "access-2" 1000))))))
(deftest prune-test
(let [ds (db/open (temp-db-path))
code #(hash-map :hash %1 :client-id "used" :user-id 1 :login "a" :redirect-uri "x"
:code-challenge "c" :scope "mcp" :expires-at %2)]
(doseq [c [(client "old-unused" 100) (client "new-unused" 900) (client "old-used" 100) (client "old-pending" 100)]]
(db/insert-client! ds c))
(db/insert-grant! ds (assoc (grant 1) :client-id "old-used"))
(db/insert-grant! ds (assoc (grant 2) :client-id "old-used" :refresh-expires-at 900))
(db/insert-code! ds (assoc (code "pending" 5000) :client-id "old-pending"))
(db/insert-code! ds (code "expired" 500))
(db/prune! ds 1000 500)
(is (nil? (db/find-client ds "old-unused")) "registered long ago and never approved")
(is (some? (db/find-client ds "new-unused")) "recent registrations get time to be used")
(is (some? (db/find-client ds "old-used")) "clients with a grant stay")
(is (some? (db/find-client ds "old-pending")) "clients with an outstanding code stay")
(is (some? (db/find-grant-by-refresh ds "refresh-1")) "a grant that can still be refreshed stays")
(is (nil? (db/find-grant-by-refresh ds "refresh-2")) "one that can't is dropped")
(is (nil? (db/take-code! ds "expired")))
(is (some? (db/take-code! ds "pending")))))
+379
View File
@@ -0,0 +1,379 @@
(ns tbd.oauth.routes-test
(:require [clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[muuntaja.core :as m]
[tbd.auth.core :as auth]
[tbd.auth.routes-test :as t]
[tbd.oauth.core :as oauth]
[tbd.oauth.db :as db]
[tbd.oauth.db-test :as db-test]
[tbd.test-util :as test-util])
(:import (java.io ByteArrayInputStream)
(java.net URLDecoder)))
;; These go through the real app with Gitea stubbed out, the way an MCP client and a browser
;; would: discovery, registration, login + consent in the browser, then the token endpoint.
(def redirect-uri "http://localhost:8123/callback")
(def verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
(def challenge (oauth/pkce-challenge verifier))
(defn json-str [data] (slurp (m/encode "application/json" data)))
(defn app-for
"An app whose OAuth database is `path`; the Gitea user is alice (id 7), an admin, unless told otherwise."
([path] (app-for path "alice" 7))
([path login id] (t/make-app (t/gitea-stub login id) :oauth {:db-path path})))
(defn call
"Runs a request through `app`. `:form` (a map) is sent as a urlencoded body, `:body` (a string) as JSON."
[app method uri & {:keys [cookie headers body form query]}]
(let [payload (or (some-> form auth/form-encode) body)
content-type (cond form "application/x-www-form-urlencoded" body "application/json")]
(test-util/decode-body
(app (cond-> {:request-method method :uri uri :query-string query :headers (or headers {})}
cookie (assoc-in [:headers "cookie"] cookie)
payload (assoc :body (ByteArrayInputStream. (.getBytes ^String payload "UTF-8")))
content-type (assoc-in [:headers "content-type"] content-type))))))
(defn query-params
"The query parameters of a response's Location header, decoded."
[resp]
(let [location (get-in resp [:headers "Location"] "")]
(into {} (for [pair (str/split (or (second (str/split location #"\?" 2)) "") #"&")
:when (seq pair)
:let [[k v] (str/split pair #"=" 2)]]
[(URLDecoder/decode ^String k "UTF-8") (URLDecoder/decode ^String (or v "") "UTF-8")]))))
(defn register!
"Registers a client; returns its client_id."
([app] (register! app {}))
([app metadata]
(:client_id (:body (call app :post "/oauth/register"
:body (json-str (merge {:redirect_uris [redirect-uri] :client_name "Test client"} metadata)))))))
(defn authorize-params [client-id & {:as overrides}]
(merge {"client_id" client-id "redirect_uri" redirect-uri "response_type" "code"
"code_challenge" challenge "code_challenge_method" "S256" "state" "st-1" "scope" "mcp"}
overrides))
(defn consent!
"Opens the consent page as the logged-in `cookie`; returns {:page :cookie :csrf}."
[app cookie params]
(let [page (call app :get "/oauth/authorize" :cookie cookie :query (auth/form-encode params))]
{:page page
:cookie (or (t/set-cookie page) cookie)
:csrf (second (re-find #"name=\"csrf\" value=\"([^\"]+)\"" (str (:body page))))}))
(defn decide!
"Submits the consent form (`decision` approve or deny); returns the response."
[app {:keys [cookie csrf]} params decision]
(call app :post "/oauth/authorize" :cookie cookie :form (assoc params "csrf" csrf "decision" decision)))
(defn token!
"Exchanges an authorization code (with the right verifier and redirect URI unless overridden)."
[app client-id code & {:as overrides}]
(call app :post "/oauth/token"
:form (merge {"grant_type" "authorization_code" "code" code "client_id" client-id
"redirect_uri" redirect-uri "code_verifier" verifier}
overrides)))
(defn authorize-code!
"The whole browser half for a logged-in user: consent page, then Approve. Returns the code."
[app cookie client-id]
(let [params (authorize-params client-id)
resp (decide! app (consent! app cookie params) params "approve")]
(get (query-params resp) "code")))
(defn get-tokens!
"The whole flow, from an empty database to a token response body. Returns {:client-id :tokens :cookie}."
[app]
(let [client-id (register! app)
cookie (t/login! app)
code (authorize-code! app cookie client-id)]
{:client-id client-id :cookie cookie :tokens (:body (token! app client-id code))}))
(deftest discovery-test
(let [app (app-for (db-test/temp-db-path))]
(testing "protected resource metadata, at the root and at the resource's own path"
(doseq [uri ["/.well-known/oauth-protected-resource" "/.well-known/oauth-protected-resource/mcp"]]
(let [resp (call app :get uri)]
(is (= 200 (:status resp)) uri)
(is (= {:resource "http://tbd.test/mcp" :authorization_servers ["http://tbd.test"]}
(select-keys (:body resp) [:resource :authorization_servers]))))))
(testing "authorization server metadata"
(let [resp (call app :get "/.well-known/oauth-authorization-server")]
(is (= 200 (:status resp)))
(is (= "http://tbd.test" (get-in resp [:body :issuer])))
(is (= "http://tbd.test/oauth/register" (get-in resp [:body :registration_endpoint])))))
(testing "JSON whatever the Accept header says (the API default is EDN)"
(is (= "application/json" (get-in (call app :get "/.well-known/oauth-authorization-server"
:headers {"accept" "application/edn"})
[:headers "Content-Type"]))))))
(deftest unconfigured-test
(let [path (db-test/temp-db-path)
app (t/make-app (t/gitea-stub "alice") :oauth {:db-path path} :gitea-url nil)]
(doseq [[method uri] [[:get "/.well-known/oauth-protected-resource"]
[:get "/.well-known/oauth-authorization-server"]
[:post "/oauth/register"]
[:get "/oauth/authorize"]
[:post "/oauth/authorize"]
[:post "/oauth/token"]]]
(is (= 503 (:status (call app method uri))) (str method " " uri)))))
(deftest register-test
(let [path (db-test/temp-db-path)
app (app-for path)]
(testing "a client is registered as a public client"
(let [resp (call app :post "/oauth/register"
:body (json-str {:redirect_uris [redirect-uri "https://c.example.com/cb"] :client_name "Claude"
:grant_types ["authorization_code" "refresh_token"] :response_types ["code"]
:token_endpoint_auth_method "none" :scope "ignored"}))
client (:body resp)]
(is (= 201 (:status resp)))
(is (= "no-store" (get-in resp [:headers "Cache-Control"])))
(is (some? (:client_id client)))
(is (= "Claude" (:client_name client)))
(is (= [redirect-uri "https://c.example.com/cb"] (:redirect_uris client)))
(is (= "none" (:token_endpoint_auth_method client)))
(is (nil? (:client_secret client)))
(is (= "Claude" (:name (db/find-client (db/open path) (:client_id client)))) "and it is stored")))
(testing "the name is optional, trimmed and capped"
(is (= "Unnamed application" (get-in (call app :post "/oauth/register" :body (json-str {:redirect_uris [redirect-uri]}))
[:body :client_name])))
(is (= 100 (count (get-in (call app :post "/oauth/register"
:body (json-str {:redirect_uris [redirect-uri] :client_name (apply str (repeat 500 "x"))}))
[:body :client_name])))))
(testing "bad registrations are refused with an RFC 7591 error"
(doseq [[why body error]
[["no body" nil "invalid_client_metadata"]
["not JSON" "nope" "invalid_client_metadata"]
["not an object" "[1]" "invalid_client_metadata"]
["no redirect_uris" (json-str {:client_name "x"}) "invalid_client_metadata"]
["empty redirect_uris" (json-str {:redirect_uris []}) "invalid_client_metadata"]
["redirect_uris of the wrong type" (json-str {:redirect_uris [42]}) "invalid_client_metadata"]
["too many redirect_uris" (json-str {:redirect_uris (repeat 11 redirect-uri)}) "invalid_client_metadata"]
["http off the local machine" (json-str {:redirect_uris ["http://evil.example.com/cb"]}) "invalid_redirect_uri"]
["javascript: URI" (json-str {:redirect_uris ["javascript:alert(1)"]}) "invalid_redirect_uri"]
["one bad URI among good ones" (json-str {:redirect_uris [redirect-uri "http://evil.example.com/cb"]}) "invalid_redirect_uri"]
["a confidential client" (json-str {:redirect_uris [redirect-uri] :token_endpoint_auth_method "client_secret_basic"})
"invalid_client_metadata"]
["an unsupported grant type" (json-str {:redirect_uris [redirect-uri] :grant_types ["password"]}) "invalid_client_metadata"]
["an unsupported response type" (json-str {:redirect_uris [redirect-uri] :response_types ["token"]}) "invalid_client_metadata"]
["an oversized body" (json-str {:redirect_uris [redirect-uri] :client_name (apply str (repeat 20000 "x"))})
"invalid_client_metadata"]]]
(let [resp (call app :post "/oauth/register" :body body)]
(is (= 400 (:status resp)) why)
(is (= error (get-in resp [:body :error])) why))))))
(deftest authorize-request-errors-test
(let [app (app-for (db-test/temp-db-path))
client-id (register! app)
cookie (t/login! app)
open (fn [params] (call app :get "/oauth/authorize" :cookie cookie :query (auth/form-encode params)))]
(testing "an unknown client or an unregistered redirect URI is shown to the user, never redirected to"
(doseq [params [(authorize-params "nope")
(authorize-params nil)
(authorize-params client-id "redirect_uri" "https://evil.example.com/cb")
(authorize-params client-id "redirect_uri" "http://localhost:8123/other")
(dissoc (authorize-params client-id) "redirect_uri")]]
(let [resp (open params)]
(is (= 400 (:status resp)) (pr-str params))
(is (nil? (get-in resp [:headers "Location"])))
(is (str/includes? (get-in resp [:headers "Content-Type"]) "text/html")))))
(testing "any other problem goes back to the client's redirect URI, with its state"
(doseq [[why params error]
[["response_type token" (authorize-params client-id "response_type" "token") "unsupported_response_type"]
["no PKCE" (dissoc (authorize-params client-id) "code_challenge" "code_challenge_method") "invalid_request"]
["plain PKCE" (authorize-params client-id "code_challenge_method" "plain") "invalid_request"]
["a malformed challenge" (authorize-params client-id "code_challenge" "short") "invalid_request"]
["an unknown scope" (authorize-params client-id "scope" "mcp admin") "invalid_scope"]
["another resource" (authorize-params client-id "resource" "https://other.example.com/mcp") "invalid_target"]]]
(let [resp (open params)
back (query-params resp)]
(is (= 303 (:status resp)) why)
(is (str/starts-with? (get-in resp [:headers "Location"]) (str redirect-uri "?")) why)
(is (= error (get back "error")) why)
(is (= "st-1" (get back "state")) why)
(is (= "http://tbd.test" (get back "iss")) why))))
(testing "a loopback client may use a different port"
(is (= 200 (:status (open (authorize-params client-id "redirect_uri" "http://localhost:54321/callback"))))))
(testing "the resource parameter is accepted when it is this server"
(is (= 200 (:status (open (authorize-params client-id "resource" "http://tbd.test/mcp"))))))
(testing "repeated parameters aren't an error, they just don't count"
(is (= 400 (:status (call app :get "/oauth/authorize" :cookie cookie
:query (str "client_id=" client-id "&client_id=" client-id))))))))
(deftest login-then-consent-test
(let [app (app-for (db-test/temp-db-path))
client-id (register! app)
query (auth/form-encode (authorize-params client-id))]
(testing "a logged-out user is sent to log in first, and back afterwards"
(let [resp (call app :get "/oauth/authorize" :query query)
login (get-in resp [:headers "Location"])]
(is (= 302 (:status resp)))
(is (str/starts-with? login "/auth/login?return_to="))
(let [start (call app :get "/auth/login" :query (second (str/split login #"\?" 2)))
done (call app :get "/auth/callback" :cookie (t/set-cookie start)
:query (str "code=abc&state=" (t/location-state start)))]
(is (= (str "/oauth/authorize?" query) (get-in done [:headers "Location"])))
(testing "where the consent page is now shown"
(let [page (call app :get "/oauth/authorize" :cookie (t/set-cookie done) :query query)]
(is (= 200 (:status page)))
(is (str/includes? (:body page) "Test client")))))))))
(deftest consent-page-test
(let [app (app-for (db-test/temp-db-path))
client-id (register! app {:client_name "<script>alert(1)</script>"})
{:keys [page csrf]} (consent! app (t/login! app) (authorize-params client-id))]
(is (= 200 (:status page)))
(testing "shows who, what for and where to, escaping what the client chose"
(is (str/includes? (:body page) "&lt;script&gt;alert(1)&lt;/script&gt;"))
(is (not (str/includes? (:body page) "<script>")))
(is (str/includes? (:body page) "alice"))
(is (str/includes? (:body page) redirect-uri)))
(testing "carries a CSRF token, and the request it will approve"
(is (some? csrf))
(doseq [k ["client_id" "redirect_uri" "code_challenge" "state"]]
(is (str/includes? (:body page) (str "name=\"" k "\"")) k)))
(testing "can't be framed or cached"
(is (= "DENY" (get-in page [:headers "X-Frame-Options"])))
(is (str/includes? (get-in page [:headers "Content-Security-Policy"]) "frame-ancestors 'none'"))
(is (= "no-store" (get-in page [:headers "Cache-Control"]))))))
(deftest consent-decision-test
(let [app (app-for (db-test/temp-db-path))
client-id (register! app)
params (authorize-params client-id)
cookie (t/login! app)]
(testing "approving redirects to the client with a code, the client's state, and the issuer"
(let [resp (decide! app (consent! app cookie params) params "approve")
back (query-params resp)]
(is (= 303 (:status resp)))
(is (str/starts-with? (get-in resp [:headers "Location"]) (str redirect-uri "?")))
(is (re-matches #"[A-Za-z0-9_-]{43}" (get back "code")))
(is (= "st-1" (get back "state")))
(is (= "http://tbd.test" (get back "iss")))))
(testing "denying redirects with access_denied and no code"
(let [back (query-params (decide! app (consent! app cookie params) params "deny"))]
(is (= "access_denied" (get back "error")))
(is (= "st-1" (get back "state")))
(is (nil? (get back "code")))))
(testing "anything but approve is a denial"
(is (= "access_denied" (get (query-params (decide! app (consent! app cookie params) params "whatever")) "error"))))
(testing "without the CSRF token, or with someone else's, nothing is approved"
(let [consent (consent! app cookie params)]
(doseq [csrf [nil "" "wrong"]]
(let [resp (decide! app (assoc consent :csrf csrf) params "approve")]
(is (= 400 (:status resp)) (pr-str csrf))
(is (nil? (get-in resp [:headers "Location"])))))
(testing "or when the form comes from a session that never saw the consent page"
(is (= 400 (:status (decide! app (assoc consent :cookie cookie) params "approve")))))))
(testing "logged out, the form does nothing"
(let [consent (consent! app cookie params)
resp (decide! app (assoc consent :cookie nil) params "approve")]
(is (= 401 (:status resp)))
(is (nil? (get-in resp [:headers "Location"])))))
(testing "the request is checked again: it can't be swapped for another after the page was shown"
(let [consent (consent! app cookie params)
resp (decide! app consent (assoc params "redirect_uri" "https://evil.example.com/cb") "approve")]
(is (= 400 (:status resp)))
(is (nil? (get-in resp [:headers "Location"])))))))
(deftest code-exchange-test
(let [path (db-test/temp-db-path)
app (app-for path)
client-id (register! app)
other-client-id (register! app)
cookie (t/login! app)
fresh-code #(authorize-code! app cookie client-id)]
(testing "a good exchange returns tokens for the user who approved"
(let [resp (token! app client-id (fresh-code))
{:keys [access_token refresh_token token_type expires_in scope]} (:body resp)]
(is (= 200 (:status resp)))
(is (= "no-store" (get-in resp [:headers "Cache-Control"])))
(is (= "Bearer" token_type))
(is (= 3600 expires_in))
(is (= "mcp" scope))
(is (not= access_token refresh_token))
(let [grant (db/find-grant-by-access (db/open path) (oauth/hash-token access_token) (System/currentTimeMillis))]
(is (= {:login "alice" :user-id 7 :client-id client-id :scope "mcp"}
(select-keys grant [:login :user-id :client-id :scope])))
(is (not (str/includes? (pr-str grant) access_token)) "only a hash is stored")
(is (not (str/includes? (pr-str grant) refresh_token))))))
(testing "a code works once"
(let [code (fresh-code)]
(is (= 200 (:status (token! app client-id code))))
(let [resp (token! app client-id code)]
(is (= 400 (:status resp)))
(is (= "invalid_grant" (get-in resp [:body :error]))))))
(testing "anything wrong with the request is invalid_grant, and burns the code"
(doseq [[why overrides] [["wrong verifier" {"code_verifier" (apply str (repeat 43 "a"))}]
["a too-short verifier" {"code_verifier" "short"}]
["wrong redirect_uri" {"redirect_uri" "http://localhost:8123/other"}]
["another client's id" {"client_id" other-client-id}]]]
(let [code (fresh-code)
resp (apply token! app (get overrides "client_id" client-id) code (mapcat identity (dissoc overrides "client_id")))]
(is (= 400 (:status resp)) why)
(is (= "invalid_grant" (get-in resp [:body :error])) why)
(is (= "invalid_grant" (get-in (token! app client-id code) [:body :error])) (str why ", then right")))))
(testing "an expired code"
(db/insert-code! (db/open path) {:hash (oauth/hash-token "old") :client-id client-id :user-id 7 :login "alice"
:redirect-uri redirect-uri :code-challenge challenge :scope "mcp" :expires-at 1})
(is (= "invalid_grant" (get-in (token! app client-id "old") [:body :error]))))
(testing "an unknown code"
(is (= "invalid_grant" (get-in (token! app client-id "nope") [:body :error]))))
(testing "an unknown client"
(is (= "invalid_client" (get-in (token! app "nope" (fresh-code)) [:body :error]))))
(testing "missing parameters"
(doseq [k ["code" "code_verifier" "redirect_uri" "client_id"]]
(let [resp (call app :post "/oauth/token"
:form (dissoc {"grant_type" "authorization_code" "code" "c" "client_id" client-id
"redirect_uri" redirect-uri "code_verifier" verifier}
k))]
(is (= "invalid_request" (get-in resp [:body :error])) k))))
(testing "other grant types and formats"
(is (= "unsupported_grant_type" (get-in (call app :post "/oauth/token" :form {"grant_type" "password"}) [:body :error])))
(is (= "unsupported_grant_type" (get-in (call app :post "/oauth/token" :body (json-str {:grant_type "authorization_code"}))
[:body :error]))
"the token endpoint takes forms, as RFC 6749 says"))))
(deftest refresh-test
(let [path (db-test/temp-db-path)
app (app-for path)
{:keys [client-id tokens]} (get-tokens! app)
refresh! (fn [refresh-token & {:as overrides}]
(call app :post "/oauth/token"
:form (merge {"grant_type" "refresh_token" "refresh_token" refresh-token "client_id" client-id}
overrides)))
live? (fn [access-token] (some? (db/find-grant-by-access (db/open path) (oauth/hash-token access-token)
(System/currentTimeMillis))))
resp (refresh! (:refresh_token tokens))
fresh (:body resp)]
(testing "a refresh token yields a new pair, and the old pair stops working"
(is (= 200 (:status resp)))
(is (= "Bearer" (:token_type fresh)))
(is (= "mcp" (:scope fresh)))
(is (not= (:access_token tokens) (:access_token fresh)))
(is (not= (:refresh_token tokens) (:refresh_token fresh)))
(is (live? (:access_token fresh)))
(is (not (live? (:access_token tokens)))))
(testing "the old refresh token is used up"
(let [reuse (refresh! (:refresh_token tokens))]
(is (= 400 (:status reuse)))
(is (= "invalid_grant" (get-in reuse [:body :error])))))
(testing "a refresh token is only good for the client it was issued to, and asking wrongly doesn't spend it"
(let [other (register! app)]
(is (= "invalid_grant" (get-in (refresh! (:refresh_token fresh) "client_id" other) [:body :error])))
(is (= 200 (:status (refresh! (:refresh_token fresh)))))))
(testing "an expired refresh token is refused, and the grant is gone"
(db/insert-grant! (db/open path) {:client-id client-id :user-id 7 :login "alice" :scope "mcp"
:access-hash "a" :access-expires-at 1
:refresh-hash (oauth/hash-token "stale") :refresh-expires-at 1 :created-at 1})
(is (= "invalid_grant" (get-in (refresh! "stale") [:body :error])))
(is (nil? (db/find-grant-by-refresh (db/open path) (oauth/hash-token "stale")))))
(testing "missing or unknown refresh tokens"
(is (= "invalid_request" (get-in (call app :post "/oauth/token" :form {"grant_type" "refresh_token" "client_id" client-id})
[:body :error])))
(is (= "invalid_grant" (get-in (refresh! "unknown") [:body :error]))))))