-
Notifications
You must be signed in to change notification settings - Fork 39
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
base: master
Are you sure you want to change the base?
Conversation
Adds an additional flow to the existing middleware to refresh expired tokens. The implementation handles multiple active grants. Expired grants that failed to refresh are removed. The refresh workflow is unconditionally activated. Since a token refresh may occur for any request, access tokens are now always added to the response's `:session`. This may break code which previously relied on the `:session` only being set during the initial grant workflow. I do not think this can be avoided. If a refresh occurs, the tokens in `(:session request)` are left as-is, the updated access tokens are accessibly via the existing `:oauth2/access-token` key. This allows downstream handlers to observe that a token refresh occurred. There is a potential bug, where concurrent requests with expired tokens may race. For example, consider a page containing a `css` and a `js` resource. If a user's access token was to expire exactly as the `index.html` finishes loading, their browser may concurrently fetch both the `css` and `js` resources, triggering two concurrent token refresh attempts with the same token, one of which may fail. I do not see a way to address this without introducing considerable complexity. I have added a timeout of 60 seconds to the refresh http request, which means a slow oauth backend will cause users to become logged out. I think this is more informative for users than hanging forever. Fixes weavejester#40
Thanks for the PR. This might take a while for me to get through. Please ensure that the commit contains only changes relevant to this feature, that all lines are within 80 characters, and that only public functions have docstrings. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I have time I'll do a more thorough review a little later, but these are my initial thoughts.
src/ring/middleware/oauth2.clj
Outdated
raise))) | ||
(http/request | ||
(-> (access-token-http-options profile | ||
(request-params profile request)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why has request-params
been moved out here? I don't understand how this change connects to the token refreshes. Can you explain?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I should have left a comment for this change, it's a bit confusing: I am trying to re-use access-token-http-options
for the refresh http request as they share the same URL and credentials logic, except for the form-params
, hence i extracted it as a parameter (in place of constructing those from the request
).
If you feel this is convoluted, a possible alternative would be to assoc
the :form-params
in the caller, same as the :async
is currently set from outside.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In which case, let's name things correctly. We can divide this into three functions:
token-http-options
to contain the common codeaccess-token-http-options
to operate as beforerefresh-token-http-options
to contain the options for refreshing tokens.
That way things are named correctly, and it makes the code a little more understandable later on.
src/ring/middleware/oauth2.clj
Outdated
@@ -188,33 +191,142 @@ | |||
(respond (redirect-response profile session token))) | |||
raise))))) | |||
|
|||
(defn- assoc-access-tokens [request] | |||
(if-let [tokens (-> request :session ::access-tokens)] | |||
(defn- get-expired |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally speaking, we should avoid using 'get-' as part of pure function names. Instead expired-access-tokens
would likely be a better name.
src/ring/middleware/oauth2.clj
Outdated
(defn- assoc-access-tokens [request] | ||
(if-let [tokens (-> request :session ::access-tokens)] | ||
(defn- get-expired | ||
"Returns expired profile keys and refresh tokens in `access-tokens`." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Docstrings on private functions should be avoided.
src/ring/middleware/oauth2.clj
Outdated
(defn- get-expired | ||
"Returns expired profile keys and refresh tokens in `access-tokens`." | ||
[access-tokens] | ||
(let [now (new Date)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer (Date.)
over (new Date)
.
src/ring/middleware/oauth2.clj
Outdated
(for [[profile-key {:keys [expires refresh-token]}] access-tokens | ||
:when (and expires refresh-token (.before expires now))] | ||
{:profile-key profile-key :refresh-token refresh-token}))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps something like:
(defn- expired-access-token? [[k {:keys [expires]}]]
(and expires (.before expires (Date.))))
(defn- expired-access-tokens [access-tokens]
(into {} (filter expired-access-token?) access-tokens)
That way we're just filtering the map and the output type is the same as the input type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
very good point! indeed this is just a filter
src/ring/middleware/oauth2.clj
Outdated
(http/request (comp respond format-access-token) raise)))) | ||
|
||
(defn- valid-token? [token] | ||
(and token (string? token) (not (str/blank? token)))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need this check? I'm unsure of its purpose.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
from my reading of the RFC, we should check whether the token is set, since it is optional for the authorizing server to provide one --- the other two predicates are... just garnish, and unlikely to do anything, so I did remove them and inline it
src/ring/middleware/oauth2.clj
Outdated
(defn- refresh-all-tokens | ||
"Refreshes all expired tokens, yielding an updated map of tokens" | ||
([profiles access-tokens] | ||
(let [refresh-results | ||
(for [{:keys [profile-key refresh-token]} (get-expired access-tokens) | ||
:let [profile (profile-key profiles)] | ||
:when (and profile (valid-token? refresh-token))] | ||
[profile-key | ||
(try (refresh-one-token profile refresh-token) | ||
(catch clojure.lang.ExceptionInfo _ | ||
nil))])] | ||
(reduce update-tokens access-tokens refresh-results))) | ||
([profiles access-tokens respond] | ||
;; strategy: launch all requests concurrently, keeping track of completed | ||
;; requests in `results`. When all requests have finished, respond. | ||
(let [expired (get-expired access-tokens) | ||
total (count expired) | ||
results (atom {}) ;; map from profile-key to result | ||
respond-when-done! #(when (= (count @results) total) | ||
(respond (reduce update-tokens access-tokens @results)))] | ||
(if (zero? total) | ||
(respond access-tokens) | ||
(doseq [{:keys [profile-key refresh-token]} expired | ||
:let [profile (profile-key profiles)] | ||
:when (and profile (valid-token? refresh-token))] | ||
(refresh-one-token profile refresh-token | ||
(fn [refresh-result] | ||
(swap! results assoc profile-key refresh-result) | ||
(respond-when-done!)) | ||
(fn [_] | ||
(swap! results assoc profile-key nil) | ||
(respond-when-done!)))))))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels as if this function is attempting to do a lot, as we can see from it's length! I think we could split this up into three logical steps:
- Collect the expired access tokens
- Send them off via HTTP to be refreshed
- Update the access token map
Only step 2 is different for sync/async. What we essentially want here is a way of running a bunch of async functions in parallel, and then to collect the results. That way we can have a function like:
(defn- refresh-expired-tokens
([profiles access-tokens]
(pmap (partial refresh-token profiles)
(expired-access-tokens access-tokens)))
([profiles access-tokens respond raise]
(amap (partial refresh-token profiles)
(expired-access-tokens access-tokens)
respond raise)))
If map
(and pmap
) expect a function (f x)
, then amap
would expect a function (f x respond raise)
.
I'm running out of review time so I don't have enough time to sketch out the implementation of amap
, but if I get time later I'll go ahead and do so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, you are right that this is a giant function; I usually refrain from writing novel combinators, but you are right that here I have an implementation of a ring-style-async-map
that could be extracted.
I will give it a try later to see if I can simplify it.
I am not sure about the use of pmap
, i do not think it is worth it to spawn a thread when in almost all cases only a single authentication provider is used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I gave it a try, but ultimately decided not to implement a generic async map; there are a handful complications which I feel are not worth the headache of generalization (error handling, preserving order); the most recent commit adds a less ambitious, specialized version that works for associative containers and assigns nil
to a key on failure. This does greatly simplify refresh-all-tokens
though, so I hope this addresses your concern 😄
src/ring/middleware/oauth2.clj
Outdated
"Middleware that handles OAuth2 authentication flows. | ||
|
||
Parameters: | ||
* `handler`: The downstream ring handler | ||
* `profiles`: A map of profiles | ||
|
||
Each request URI is matched against the profiles to determine the appropriate | ||
OAuth2 flow handler. If no match is found, the request is passed to the | ||
downstream handler with existing access tokens added to the request under the | ||
`:oauth2/access-tokens` key. | ||
|
||
Expired tokens are refreshed using their refresh-token if possible. If refresh | ||
fails, the access token is removed." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This docstring addition should be part of another commit ideally.
src/ring/middleware/oauth2.clj
Outdated
refreshed-tokens (refresh-all-tokens profiles access-tokens)] | ||
(-> request | ||
(assoc-access-tokens-in-request refreshed-tokens) | ||
handler | ||
(assoc-access-tokens-in-response refreshed-tokens)))))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if there are no refreshed tokens?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i assume the naming was too confusing here --- the refreshed-tokens
contain both the newly refreshed tokens as well as the existing, non-expired tokens.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @weavejester thanks for the very prompt and thorough review 😄
I will see about doing some more work to refactor the giant function you mentioned, don't hesitate to let me know if anything else looks clunky 😅
src/ring/middleware/oauth2.clj
Outdated
(for [[profile-key {:keys [expires refresh-token]}] access-tokens | ||
:when (and expires refresh-token (.before expires now))] | ||
{:profile-key profile-key :refresh-token refresh-token}))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
very good point! indeed this is just a filter
src/ring/middleware/oauth2.clj
Outdated
(http/request (comp respond format-access-token) raise)))) | ||
|
||
(defn- valid-token? [token] | ||
(and token (string? token) (not (str/blank? token)))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
from my reading of the RFC, we should check whether the token is set, since it is optional for the authorizing server to provide one --- the other two predicates are... just garnish, and unlikely to do anything, so I did remove them and inline it
src/ring/middleware/oauth2.clj
Outdated
(defn- refresh-all-tokens | ||
"Refreshes all expired tokens, yielding an updated map of tokens" | ||
([profiles access-tokens] | ||
(let [refresh-results | ||
(for [{:keys [profile-key refresh-token]} (get-expired access-tokens) | ||
:let [profile (profile-key profiles)] | ||
:when (and profile (valid-token? refresh-token))] | ||
[profile-key | ||
(try (refresh-one-token profile refresh-token) | ||
(catch clojure.lang.ExceptionInfo _ | ||
nil))])] | ||
(reduce update-tokens access-tokens refresh-results))) | ||
([profiles access-tokens respond] | ||
;; strategy: launch all requests concurrently, keeping track of completed | ||
;; requests in `results`. When all requests have finished, respond. | ||
(let [expired (get-expired access-tokens) | ||
total (count expired) | ||
results (atom {}) ;; map from profile-key to result | ||
respond-when-done! #(when (= (count @results) total) | ||
(respond (reduce update-tokens access-tokens @results)))] | ||
(if (zero? total) | ||
(respond access-tokens) | ||
(doseq [{:keys [profile-key refresh-token]} expired | ||
:let [profile (profile-key profiles)] | ||
:when (and profile (valid-token? refresh-token))] | ||
(refresh-one-token profile refresh-token | ||
(fn [refresh-result] | ||
(swap! results assoc profile-key refresh-result) | ||
(respond-when-done!)) | ||
(fn [_] | ||
(swap! results assoc profile-key nil) | ||
(respond-when-done!)))))))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, you are right that this is a giant function; I usually refrain from writing novel combinators, but you are right that here I have an implementation of a ring-style-async-map
that could be extracted.
I will give it a try later to see if I can simplify it.
I am not sure about the use of pmap
, i do not think it is worth it to spawn a thread when in almost all cases only a single authentication provider is used.
src/ring/middleware/oauth2.clj
Outdated
refreshed-tokens (refresh-all-tokens profiles access-tokens)] | ||
(-> request | ||
(assoc-access-tokens-in-request refreshed-tokens) | ||
handler | ||
(assoc-access-tokens-in-response refreshed-tokens)))))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i assume the naming was too confusing here --- the refreshed-tokens
contain both the newly refreshed tokens as well as the existing, non-expired tokens.
src/ring/middleware/oauth2.clj
Outdated
raise))) | ||
(http/request | ||
(-> (access-token-http-options profile | ||
(request-params profile request)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I should have left a comment for this change, it's a bit confusing: I am trying to re-use access-token-http-options
for the refresh http request as they share the same URL and credentials logic, except for the form-params
, hence i extracted it as a parameter (in place of constructing those from the request
).
If you feel this is convoluted, a possible alternative would be to assoc
the :form-params
in the caller, same as the :async
is currently set from outside.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you very much for the updates! I have some additional feedback, but we're getting there. One thing that does need to be taken account of is what happens if the handler doesn't return a session. In that case, we should take the session from the request instead.
src/ring/middleware/oauth2.clj
Outdated
raise))) | ||
(http/request | ||
(-> (access-token-http-options profile | ||
(request-params profile request)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In which case, let's name things correctly. We can divide this into three functions:
token-http-options
to contain the common codeaccess-token-http-options
to operate as beforerefresh-token-http-options
to contain the options for refreshing tokens.
That way things are named correctly, and it makes the code a little more understandable later on.
src/ring/middleware/oauth2.clj
Outdated
(defn- expired-access-tokens | ||
[access-tokens] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can omit the newline before the argument list if it all fits on one line, e.g.
(defn- expired-access-tokens [access-tokens]
Is just as clear and uses less vertical whitespace.
src/ring/middleware/oauth2.clj
Outdated
(let [now (Date.) | ||
expired-access-token? (fn [[_ {:keys [expires refresh-token]}]] | ||
(and refresh-token expires | ||
(.before expires now)))] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need to bother factoring the now
out. It's not going to make much of a difference that I can see, and makes the code a little harder to parse. In addition, other parts of the code already use their own "now".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- extracting it leaves
expired-access-token?
a pure function - there is a potentially confusing outcome where two tokens with identical expiration time take go different branches when expiration coincides with the evaluating of the
filter
you are right that this has 0 practical impact, I will make the change tomorrow 😄
src/ring/middleware/oauth2.clj
Outdated
(-> (access-token-http-options | ||
profile | ||
{:grant_type "refresh_token" :refresh_token refresh-token}) | ||
(assoc :socket-timeout refresh-socket-timeout) | ||
http/request | ||
format-access-token)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this would be clearer:
(-> (http/request (refresh-token-http-options profile refresh-token))
(format-access-token))
src/ring/middleware/oauth2.clj
Outdated
(let [access-tokens (->> (get-in request [:session ::access-tokens]) | ||
(refresh-all-tokens profiles))] | ||
(-> request | ||
(assoc-access-tokens-in-request access-tokens) | ||
handler | ||
(assoc-access-tokens-in-response access-tokens)))))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could probably be factored out into it's own function. So something like:
(defn- wrap-refresh-access-tokens [handler profiles]
(fn
([request] ...)
([request respond raise] ...)))
It should also only update the session if access tokens have expired or refreshed, otherwise the session should not be touched.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure updating the session in the response if changed is a good idea, as for users this might be rather surprising: If they previously relied on the :session
key remaining unset, the breaking behavior only occurs when a token expires, which might be rather hard to debug.
But now that I think about this, we have to be careful as to not prevent users from implementing logout in handler
. This could be addressed by checking if :session
is explicitly set to nil
, in which case we don't touch it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The rules that govern the session middleware are:
- If the response
:session
key is missing the session is unaltered. - If the response
:session
key isnil
, the session is removed. - If the response
:session
key is a map, it replaces the current session.
While it's possible to always replace the session with an identical result, it's not necessary and adds a session write command that may not be necessary. This might be a database update, for example. For that reason we need to only write the session if we want to alter it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the clarification; The performance overhead is a strong point and I have changed the behavior to only update the :session
in the response if the access tokens have changed and if the handler did not already set the session.
src/ring/middleware/oauth2.clj
Outdated
(defn- assoc-access-tokens-in-response | ||
[response tokens] | ||
(if tokens | ||
(assoc-in response [:session ::access-tokens] tokens) | ||
response)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This assumes a :session
key in the response map from the handler, which I don't believe is something you can rely on.
src/ring/middleware/oauth2.clj
Outdated
(defn wrap-oauth2 | ||
[handler profiles] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Newline can be omitted.
- extract `wrap-refresh-acceess-tokens` - only set response `:session` if changed and if handler did not set it already - add a test for the above
src/ring/middleware/oauth2.clj
Outdated
(assoc request :oauth2/access-tokens tokens) | ||
request)) | ||
|
||
(defn- assoc-access-tokens-in-response | ||
[original-tokens updated-tokens response] | ||
(if (and (not (contains? response :session)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
hi @studer-l , I'm working on an app that uses oauth2 and I would need token refresh - since I have 5 min tokens. Thanks, |
Hi @ieugen |
Adds an additional flow to the existing middleware to refresh expired
tokens. The implementation handles multiple active grants. Expired
grants that failed to refresh are removed.
The refresh workflow is unconditionally activated. Since a token refresh
may occur for any request, access tokens are now always added to the
response's
:session
. This may break code which previously relied on the:session
only being set during the initial grant workflow. I do notthink this can be avoided.
If a refresh occurs, the tokens in
(:session request)
are left as-is,the updated access tokens are accessibly via the existing
:oauth2/access-token
key. This allows downstream handlers to observethat a token refresh occurred.
There is a potential bug, where concurrent requests with expired tokens
may race. For example, consider a page containing a
css
and ajs
resource. If a user's access token was to expire exactly as the
index.html
finishes loading, their browser may concurrently fetch boththe
css
andjs
resources, triggering two concurrent token refreshattempts with the same token, one of which may fail. I do not see a way to
address this without introducing considerable complexity.
I have added a timeout of 60 seconds to the refresh http request, which
means a slow oauth backend will cause users to become logged out. I
think this is more informative for users than hanging forever.
Fixes #40