Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refresh expired access tokens #56

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
119 changes: 107 additions & 12 deletions src/ring/middleware/oauth2.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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))
Expand Down Expand Up @@ -188,11 +191,102 @@
(respond (redirect-response profile session token)))
raise)))))

(defn- assoc-access-tokens [request]
(if-let [tokens (-> request :session ::access-tokens)]
(defn- expired-access-tokens [access-tokens]
(let [expired-access-token? (fn [[_ {:keys [expires refresh-token]}]]
(and refresh-token expires
(.before expires (Date.))))]
(->> access-tokens (filter expired-access-token?) (into {}))))

(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)))

(def refresh-socket-timeout 60000)

(defn- refresh-token-request-options [profile refresh-token]
(-> (token-http-options profile {:grant_type "refresh_token"
:refresh_token refresh-token})
(assoc :socket-timeout refresh-socket-timeout)))

(defn- refresh-one-token
([profile refresh-token]
(-> (http/request (refresh-token-request-options profile refresh-token))
format-access-token))
([profile refresh-token respond raise]
(let [opts (-> (refresh-token-request-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 %))
raise (fn [_] (respond nil))]]
(f v respond raise)))))

(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 clojure.lang.ExceptionInfo _ nil))]))
(reduce update-tokens access-tokens)))
([profiles access-tokens respond]
(async-map-values
(fn [[profile refresh-token] respond raise]
(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- assoc-access-tokens-in-response
[original-tokens updated-tokens response]
(if (and (not (contains? response :session))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't look quite correct. I believe it should be:

(and (not (and (contains? response :session)
               (nil? (:session response))))
     (not= original-tokens updated-tokens))

Or more clearly:

(and (not (nil-session? response))
     (not= original-tokens updated-tokens))

In that the only time we don't want to update the response is if the tokens haven't changed, or the session has been set explicitly to nil and is therefore being deleted.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be addressed in 26f2b34, with de morgan's law and branch order flipped

(not= original-tokens updated-tokens))
(assoc-in response [:session ::access-tokens] updated-tokens)
response))

(defn- wrap-refresh-access-tokens [handler profiles]
(fn ([request]
(let [access-tokens (get-in request [:session ::access-tokens])
updated-access-tokens (refresh-all-tokens profiles access-tokens)
response (handler (assoc-access-tokens-in-request request
updated-access-tokens))]
(assoc-access-tokens-in-response access-tokens updated-access-tokens
response)))
([request respond raise]
(let [access-tokens (get-in request [:session ::access-tokens])
respond
(fn [updated-access-tokens]
(let [request (assoc-access-tokens-in-request
request updated-access-tokens)
update-session (partial assoc-access-tokens-in-response
access-tokens updated-access-tokens)
respond (comp respond update-session)]
(handler request respond raise)))]
(refresh-all-tokens profiles access-tokens respond)))))

(defn- parse-redirect-url [{:keys [redirect-uri]}]
(.getPath (java.net.URI. redirect-uri)))

Expand All @@ -201,20 +295,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)))))))
132 changes: 130 additions & 2 deletions test/ring/middleware/oauth2_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -390,3 +391,130 @@
(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)))))))))