Files
tbd/test/tbd/oauth/routes_test.clj
ajet 187252177c
CI / Test + coverage (push) Successful in 46s
merge db sources
2026-09-21 14:11:00 -09:00

380 lines
23 KiB
Clojure

(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.sqlite-test :as sqlite-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) :db {: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 (sqlite-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 (sqlite-test/temp-db-path)
app (t/make-app (t/gitea-stub "alice") :db {: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 (sqlite-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 (sqlite-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 (sqlite-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 (sqlite-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 (sqlite-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 (sqlite-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 (sqlite-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]))))))