diff --git a/src/ring/middleware/oauth2.clj b/src/ring/middleware/oauth2.clj index f4a52f9..780bf8c 100644 --- a/src/ring/middleware/oauth2.clj +++ b/src/ring/middleware/oauth2.clj @@ -98,7 +98,7 @@ (defn- get-code-verifier [request] (get-in request [:session ::code-verifier])) -(defn- request-params [{:keys [pkce?] :as profile} request] +(defn- access-token-request-params [{:keys [pkce?] :as profile} request] (-> {:grant_type "authorization_code" :code (get-authorization-code request) :redirect_uri (redirect-uri profile request)} @@ -112,19 +112,22 @@ (merge {:client_id id :client_secret secret})))) -(defn- access-token-http-options +(defn- token-http-options [{:keys [access-token-uri client-id client-secret basic-auth?] - :or {basic-auth? false} :as profile} - request] + :or {basic-auth? false}} + form-params] (let [opts {:method :post :url access-token-uri :accept :json :as :json - :form-params (request-params profile request)}] + :form-params form-params}] (if basic-auth? (add-header-credentials opts client-id client-secret) (add-form-credentials opts client-id client-secret)))) +(defn- access-token-http-options [profile request] + (token-http-options profile (access-token-request-params profile request))) + (defn- get-access-token ([profile request] (-> (http/request (access-token-http-options profile request)) @@ -188,11 +191,109 @@ (respond (redirect-response profile session token))) raise))))) -(defn- assoc-access-tokens [request] - (if-let [tokens (-> request :session ::access-tokens)] +(defn- expired-access-token? [{:keys [expires refresh-token]}] + (and refresh-token expires (.before expires (Date.)))) + +(defn expired-access-tokens [tokens] + (into {} (filter (comp expired-access-token? val)) tokens)) + +(defn- update-tokens [access-tokens [profile-key maybe-grant]] + (if maybe-grant + ;; `update ... merge` to properly handle case where authorization server + ;; does not update the refresh token after use and we should re-use the + ;; existing refresh token + (update access-tokens profile-key merge maybe-grant) + (dissoc access-tokens profile-key))) + +(defn- refresh-token-http-options [profile refresh-token] + (token-http-options profile {:grant_type "refresh_token" + :refresh_token refresh-token})) + +(defn- refresh-one-token + ([profile refresh-token] + (-> (http/request (refresh-token-http-options profile refresh-token)) + format-access-token)) + ([profile refresh-token respond raise] + (let [opts (-> (refresh-token-http-options profile refresh-token) + (assoc :async? true))] + (http/request opts (comp respond format-access-token) raise)))) + +(defn- refresh-tasks [profiles access-tokens] + (->> (expired-access-tokens access-tokens) + (keep (fn [[profile-key {:keys [refresh-token]}]] + (when (and (get profiles profile-key) refresh-token) + [profile-key [(get profiles profile-key) refresh-token]]))))) + +(defn- async-map-values [f respond m] + (let [total (count m) + results (atom {}) + respond-when-done #(when (= (count %) total) (respond %))] + (if (zero? total) + (respond {}) + (doseq [[k v] m] + (let [respond #(respond-when-done (swap! results assoc k %))] + (f v respond)))))) + +(defn- refresh-all-tokens + ([profiles access-tokens] + (->> (refresh-tasks profiles access-tokens) + (map (fn [[profile-key [profile refresh-token]]] + [profile-key + (try (refresh-one-token profile refresh-token) + (catch Exception _ nil))])) + (reduce update-tokens access-tokens))) + ([profiles access-tokens respond] + (async-map-values + (fn [[profile refresh-token] respond] + ;; on failure, yield a result of `nil` as refreshed token to signal error + (let [raise (fn [_] (respond nil))] + (refresh-one-token profile refresh-token respond raise))) + (fn [refreshed-tokens] + (respond + (reduce update-tokens access-tokens refreshed-tokens))) + (refresh-tasks profiles access-tokens)))) + +(defn- assoc-access-tokens-in-request [request tokens] + (if tokens (assoc request :oauth2/access-tokens tokens) request)) +(defn- nil-session? [response] + (and (contains? response :session) (nil? (:session response)))) + +(defn- get-current-session [request response] + (if (contains? response :session) + (:session response) + (:session request))) + +(defn- assoc-access-tokens-in-response + [request original-tokens updated-tokens response] + (if (or (nil-session? response) + (= original-tokens updated-tokens)) + ;; either handler explicitly cleared session or no token refresh occurred + response + ;; otherwise add refreshed tokens to current session + (let [session (-> (get-current-session request response) + (assoc ::access-tokens updated-tokens))] + (assoc response :session session)))) + +(defn- wrap-refresh-access-tokens [handler profiles] + (fn ([request] + (let [tokens (get-in request [:session ::access-tokens]) + tokens' (refresh-all-tokens profiles tokens) + request (assoc-access-tokens-in-request request tokens') + response (handler request)] + (assoc-access-tokens-in-response request tokens tokens' response))) + ([request respond raise] + (let [tokens (get-in request [:session ::access-tokens])] + (refresh-all-tokens + profiles tokens + (fn [tokens'] + (let [request (assoc-access-tokens-in-request request tokens') + respond #(respond (assoc-access-tokens-in-response + request tokens tokens' %))] + (handler request respond raise)))))))) + (defn- parse-redirect-url [{:keys [redirect-uri]}] (.getPath (java.net.URI. redirect-uri))) @@ -201,20 +302,21 @@ (defn wrap-oauth2 [handler profiles] {:pre [(every? valid-profile? (vals profiles))]} - (let [profiles (for [[k v] profiles] (assoc v :id k)) - launches (into {} (map (juxt :launch-uri identity)) profiles) - redirects (into {} (map (juxt parse-redirect-url identity)) profiles)] + (let [id-profiles (for [[k v] profiles] (assoc v :id k)) + launches (into {} (map (juxt :launch-uri identity)) id-profiles) + redirects (into {} (map (juxt parse-redirect-url identity)) id-profiles) + handler (wrap-refresh-access-tokens handler profiles)] (fn ([{:keys [uri] :as request}] (if-let [profile (launches uri)] ((make-launch-handler profile) request) (if-let [profile (redirects uri)] ((:redirect-handler profile (make-redirect-handler profile)) request) - (handler (assoc-access-tokens request))))) + (handler request)))) ([{:keys [uri] :as request} respond raise] (if-let [profile (launches uri)] ((make-launch-handler profile) request respond raise) (if-let [profile (redirects uri)] ((:redirect-handler profile (make-redirect-handler profile)) request respond raise) - (handler (assoc-access-tokens request) respond raise))))))) + (handler request respond raise))))))) diff --git a/test/ring/middleware/oauth2_test.clj b/test/ring/middleware/oauth2_test.clj index 92151f1..18a2812 100644 --- a/test/ring/middleware/oauth2_test.clj +++ b/test/ring/middleware/oauth2_test.clj @@ -109,8 +109,9 @@ b-ms (.getTime b)] (< (- a-ms 1000) b-ms (+ a-ms 1000)))) -(defn- seconds-from-now-to-date [secs] - (-> (Instant/now) (.plusSeconds secs) (Date/from))) +(defn- seconds-from-now-to-date + ([now secs] (-> now (.plusSeconds secs) (Date/from))) + ([secs] (seconds-from-now-to-date (Instant/now) secs))) (deftest test-redirect-uri (fake/with-fake-routes @@ -390,3 +391,174 @@ (deref raise 100 :empty))) (is (= {:status 200, :headers {}, :body tokens} (deref respond 100 :empty))))))) + +(def refresh-token-response + {:status 200 + :headers {"Content-Type" "application/json"} + :body "{\"access_token\":\"newtoken\",\"expires_in\":3600, + \"refresh_token\":\"newrefresh\",\"foo\":\"bar\"}"}) + +(deftest test-token-refresh-success + (fake/with-fake-routes + {"https://example.com/oauth2/access-token" + (fn [req] + (let [params (codec/form-decode (slurp (:body req)))] + (is (= "refresh_token" (get params "grant_type"))) + (is (= "oldrefresh" (get params "refresh_token"))) + refresh-token-response))} + + (let [now (Instant/now) + old-expires (seconds-from-now-to-date now -60) + new-expires (seconds-from-now-to-date now 3600) + new-token {:token "newtoken" + :refresh-token "newrefresh" + :extra-data {:foo "bar"}} + request (-> (mock/request :get "/") + (assoc :session + {::oauth2/access-tokens + {:test {:token "oldtoken" + :refresh-token "oldrefresh" + :expires old-expires}}}))] + (testing "sync refresh" + (let [response (test-handler request)] + (is (= 200 (:status response))) + ;; then handler has new token + (is (= new-token (dissoc (get-in response [:body :test]) :expires))) + (is (approx-eq new-expires (get-in response [:body :test :expires]))) + ;; and the user's session is updated + (is (= new-token + (dissoc (get-in response + [:session ::oauth2/access-tokens :test]) + :expires))))) + (testing "async refresh" + (let [respond (promise) + raise (promise)] + (test-handler request respond raise) + (is (= :empty (deref raise 100 :empty))) + (let [response (deref respond 100 :empty)] + ;; then handler has new token + (is (not= response :empty)) + (is (= new-token (dissoc (get-in response [:body :test]) :expires))) + ;; user session is updated + (is (= new-token + (dissoc (get-in response [:session ::oauth2/access-tokens + :test]) + :expires))))))))) + +(def refresh-token-error-response + {:headers {"content-type" "application/json"}, + :status 400, + :body "{\"error\": \"invalid_grant\"}"}) + +(deftest test-token-refresh-failure + (fake/with-fake-routes + {"https://example.com/oauth2/access-token" + (constantly refresh-token-error-response)} + + ;; setup a session with two grants, where one grant is expired and which + ;; will error on refresh + (let [profiles {:test-0 test-profile :test-1 test-profile} + handler (wrap-oauth2 token-handler profiles) + good-grant {:token "good-token" + :refresh-token "refresh-token" + :expires (seconds-from-now-to-date 3600)} + expired-grant {:token "expired-token" + :refresh-token "invalid" + :expires (seconds-from-now-to-date -60)} + request (-> (mock/request :get "/") + (assoc :session + {::oauth2/access-tokens + {:test-0 expired-grant + :test-1 good-grant}}))] + (testing "sync handler" + (let [response (handler request)] + (is (= {:test-1 good-grant} + (:body response))))) + (testing "async refresh" + (let [respond (promise) + raise (promise)] + (handler request respond raise) + (is (= :empty (deref raise 100 :empty))) + (let [response (deref respond 100 :empty)] + (is (not= response :empty)) + (is (= {:test-1 good-grant} (:body response))))))))) + +(deftest test-token-refresh-clear-session + (fake/with-fake-routes + {"https://example.com/oauth2/access-token" + (constantly refresh-token-response)} + + (let [clear-response {:status 200 :headers {} :body nil :session nil} + session-clear-handler (fn + ([_request] clear-response) + ([_request respond _raise] + (respond clear-response))) + handler (wrap-oauth2 session-clear-handler {:test test-profile}) + now (Instant/now) + old-expires (seconds-from-now-to-date now -60) + request (-> (mock/request :get "/") + (assoc :session + {::oauth2/access-tokens + {:test {:token "oldtoken" + :refresh-token "oldrefresh" + :expires old-expires}}}))] + + (testing "sync handler" + (let [response (handler request)] + (is (= 200 (:status response))) + (is (nil? (:session response))))) + + (testing "async handler" + (let [respond (promise) + raise (promise)] + (handler request respond raise) + (let [response (deref respond 100 :empty) + error (deref raise 100 :empty)] + (is (not= :empty response)) + (is (= :empty error)) + (is (= 200 (:status response))) + (is (nil? (:session response))))))))) + +(deftest test-token-refresh-preserves-session-state + (fake/with-fake-routes + {"https://example.com/oauth2/access-token" + (constantly refresh-token-response)} + + (let [now (Instant/now) + old-expires (seconds-from-now-to-date now -60) + request (-> (mock/request :get "/") + (assoc :session + {:user-id 123 ; extra session state + ::oauth2/access-tokens + {:test {:token "oldtoken" + :refresh-token "oldrefresh" + :expires old-expires}}}))] + + (testing "handler sets new session state during refresh" + (let [handler (wrap-oauth2 + (fn + ([_] {:status 200 :body "ok" + :session {:user-id 123 :cart-items 5}}) + ([_ respond _] (respond {:status 200 :body "ok" + :session {:user-id 123 + :cart-items 5}}))) + {:test test-profile}) + response (handler request)] + ;; Handler's session changes preserved + (is (= 5 (get-in response [:session :cart-items]))) + ;; Refreshed token added to handler's session + (is (= "newtoken" (get-in response [:session ::oauth2/access-tokens + :test :token]))))) + + (testing "handler doesn't change session, extra state preserved" + (let [handler (wrap-oauth2 + (fn + ([_] {:status 200 :body "ok"}) + ([_ respond _] (respond {:status 200 :body "ok"}))) + {:test test-profile}) + response (handler request)] + ;; Original session's extra state preserved + (is (= 123 (get-in response [:session :user-id]))) + ;; Token refreshed + (is (= "newtoken" (get-in response [:session ::oauth2/access-tokens + :test :token]))))))))