-
Notifications
You must be signed in to change notification settings - Fork 40
Refresh expired access tokens #56
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
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
(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
(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.
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
(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.
- 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
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 |
Hi, Anything I can do to help? Eugen |
Hey @weavejester sorry to bother you with this again, I've gone through your comments again and I think they should all be addressed. |
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 updated changes. I think we're nearly there. I've made a few more suggestions in order to improve code clarity.
(fn [[profile refresh-token] respond raise] | ||
(refresh-one-token profile refresh-token respond raise)) |
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 the synchronous map
, the error catching function is in the map iteration function, but in async-map-values
, the error catching function is built in.
My suggestion would be to make these two maps consistent. We assume that the mapping function, f
, will not raise an exception:
(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]
(f v #(respond-when-done (swap! results assoc k %)))))
We then do the equivalent of "catching" the exception when calling it in refresh-all-tokens
:
(async-map-values
(fn [[profile refresh-token] respond]
(refresh-one-token profile refresh-token respond (fn [_] (respond nil)))
(fn [refreshed-tokens]
(respond (reduce update-tokens access-tokens refreshed-tokens)))
(refresh-tasks profiles 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.
Indeed your refactor is more elegant, but its behavior differs from what I initially wrote. Assume two tokens require refresh, one of which fails:
- When following your suggestion,
refresh-all-tokens
's continuation is called withnil
. So no refresh occurs. - In the version you reviewed, the
results
inasync-map-values
will accumulate both anil
and a new access token, and the user's session will receive the one updated access token. The failed token will be retried on subsequent requests.
Do you have a preferred behavior in this case?
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