This commit is contained in:
@@ -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?"))
|
||||
|
||||
@@ -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)))]
|
||||
|
||||
@@ -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}))))))
|
||||
@@ -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"])))))
|
||||
@@ -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")))))
|
||||
@@ -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) "<script>alert(1)</script>"))
|
||||
(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]))))))
|
||||
Reference in New Issue
Block a user