-
Notifications
You must be signed in to change notification settings - Fork 0
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
Handle expired refresh tokens #237
Conversation
…hat complete logout process
I'll review this on Wednesday. |
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.
Submitting the review now so that my review comments become public, and then I will re-read the PR description and post a follow-up comment.
let logoutTimer: NodeJS.Timeout; | ||
const msUntilExpiration = refreshTokenExpiration.getTime() - Date.now(); | ||
if (msUntilExpiration < MAX_DELAY_MS) { | ||
logoutTimer = setTimeout(logoutAndRedirect, msUntilExpiration); | ||
} |
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'm thinking through this algorithm. If today is January 1 and the token expires January 3, we will see that the expiration date is sooner than 7 days (i.e. MAX_DELAY_MS
) from now, so will set the timeout. But the timeout is still being set for the token expiration time. Did you mean to instead set it to be earlier than that?
Caveat: At this point in the PR review, I haven't read the rest of the diff yet.
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.
Also, I am wondering how setTimeout
works when an app does not remain in use throughout the entire timeout period. For example, I am wondering whether the countdown pauses while the app is closed and, when the app is reopened, it is as though no time has passed since it was closed (e.g., timer has progressed by 5 minutes, while real-world clock has progressed by 5 hours) — or whether the countdown (effectively) continues while the app is closed and, when the app is reopened, it is as though that much real-world time has passed (e.g., timer and real-world clock have both progressed by 5 hours).
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.
But the timeout is still being set for the token expiration time. Did you mean to instead set it to be earlier than that?
No, my rationale is that this is covering the case where the user is actively using the app when the refresh token expires. But in that case they will have an active access token, which may be expiring some time between 1 second and 24 hours from now. The user could technically make successful API requests in that period while the access token is active but the refresh token is expired. That gives us a natural buffer period and we don't need to force the log out any amount of time before the refresh token expiration.
I am wondering whether the countdown pauses while the app is closed
The state of the timer is not persisted when the app closes. The timer just goes away at that point (similar to when a web page is closed).
when the app is reopened
When the app is reopened, a new timer is setup (if it's within the MAX_DELAY_MS
threshold) with no connection to the (non-existent) ghosts of timers from sessions past.
Sorry to say, I think I'm out of time in terms of what I can afford to spend reviewing this PR this week, given unrelated tasks and our plans to submit to the app stores for an initial review by Monday. I'm OK with this being merged in, given that it does address a previously-unaddressed scenario:
I would like to think more about that scenario, but that isn't something I would be able to do this week. We can revisit this in the future. |
Co-authored-by: eecavanna <134325062+eecavanna@users.noreply.github.com>
Fixes #118
Apologies in advance for this novel 👇🏻
Background
A quick reminder about how authenticated requests to
nmdc-server
API endpoints work and what is currently implemented:401 Unauthorized
response./auth/refresh
endpoint. If the refresh token is valid (it is valid for 365 days), the/auth/refresh
endpoint will return a new access token.401 Unauthorized
response can be retried with the new access token.What isn't currently being handled is if the call to
/auth/refresh
in step 4 also returns a401 Unauthorized
response because the refresh token has expired.Summary
The changes here might be considered what our colleague Mark Miller would call a "belt-and-suspenders" approach. Although I'll admit there might be 2 belts and 3 pairs of suspenders here. In other words, there is a lot of redundancy. The reason for the redundancy is that the need for offline support (producing paused API requests) and some oddities about when Android devices resume paused mutations produce a lot of odd edge cases.
At a high level, the approach is two-fold:
Details
src/useLogoutAndRedirect.ts
) to facilitate the log out action. The function provided by this hook differs from thelogout
function provided by theStore
in that it also does a bit of offline query cleanup (note: just queries! not mutations!), presents a toast message to the user, and returns them to the welcome screen.src/Store.tsx
we now track when the time that the refresh token will expire. This is done by decoding the JWT refresh token and extracting theexp
field.RefreshTokenExpirationMonitor
) picks up the refresh token expiration time and, if that time is soon-ish, sets up a timer to perform the logout action.SettingsUserList
).src/api.ts
we define a newError
subclass,RefreshTokenExchangeError
. This error gets thrown if a call to/auth/refresh
goes out and fails due to a401 Unauthorized
response or can't go out due to a missing refresh token.onError
callbacks. These changes to instruct React Query to rethrow the error in the render phase (throwOnError
) if it is an instance ofRefreshTokenExchangeError
. The error can then be caught by a new error boundaryTokenRefreshErrorBoundary
. Finally whenTokenRefreshErrorBoundary
catches an error it can perform the logout action.TokenRefreshErrorBoundary
is wrapped around pages where we make authenticated API requests. Mostly this is handled by adding it toAuthRoute
. The one exception is theHomePage
, which does make authenticated API requests if you are logged in, but is also accessible if you are not. So that needed to be wrapped withTokenRefreshErrorBoundary
inRouter
.onSuccess
callback (src/QueryClientProvider.tsx
) as an opportunity to resume the paused mutations. However the refresh token might have expired while the app was closed. So these changes add a check to see if the user is logged in before resuming because if they're not logged in those resumed mutation are guaranteed to fail. Since paused mutation might not have been resumed after the cache rehydration, we also want to attempt to resume any paused mutations just after login (TokenPage.tsx
).NmdcQueryClient
) where I override theresumePausedMutations
method so that it includes a check for an active refresh token. If it's missing the offline mutations will stay paused until the next login.StudyForm
component. So I made it always visible at the bottom of the screen. In addition to solving my own annoyance I think it's a good UX improvement regardless. It did require adding some additional CSS color variables to make it look nice.