Repository navigation
fix(ui): refresh expired OIDC tokens before API requests - #128
Open
satriadhikara wants to merge 1 commit into
Open
satriadhikara wants to merge 1 commit into
satriadhikara wants to merge 1 commit into
Conversation
The Axios request interceptor read canRefresh from the useOidcAuth() return value and tested the loggedIn ref itself, so it never refreshed and kept sending expired access tokens (401 on save). Check the session's expireAt with a 30s margin before each authenticated request. Refresh through the session endpoint, which refreshes server-side or clears a dead session without navigating away, and share one pending call between concurrent requests. When the session cannot be refreshed, ask the user to sign in again. Keep the add/edit tuple dialog input as a draft in sessionStorage so it survives the dialog closing on session loss and the sign-in redirect. The draft is restored when the same dialog is reopened and cleared on save or cancel. Closes #124
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The UI kept sending expired OIDC access tokens, so saving table data failed with 401 until the page was reloaded. This PR fixes the shared Axios request interceptor so it refreshes the session before an authenticated request when the access token is about to expire. It also keeps unsaved row input when the session can no longer be refreshed and the user has to sign in again.
Root cause
dbrepo-ui/composables/axios-instance.tsdestructuredcanRefreshfromuseOidcAuth(). Innuxt-oidc-auth1.0.0-beta.10 that field only exists onuser.value, so the refresh branch never ran. The interceptor also tested!loggedIninstead of!loggedIn.value(aComputedRefis always truthy). The token-expiry helpers in the file were unused and referencedjwtDecode/Tokenwithout importing them.Change
loggedIn.valueanduser.value.expireAt.expireAtis the access token'sexpin seconds (set by the module on login and on refresh), so the JWT does not need to be decoded again. The unused helpers are removed.useOidcAuth().fetch()(GET /api/_auth/session). WithexpirationCheckandautomaticRefreshenabled, that endpoint refreshes the token server-side. The Keycloak preset setsexpirationThreshold: 240, so it always refreshes when the client asks. If the refresh fails, it clears the session and returns{}. I did not userefresh()because it navigates to the logout URL when the refresh fails, which throws away whatever the user was editing.user.value.accessToken. Concurrent requests share one pending refresh promise.error.auth.expired). The request is then sent without the stale token, the same way it is sent for a signed-out user. The layout already switches to the Login button becauseloggedInbecomes false.components/dialogs/EditTuple.vue). While the user types, the row is saved as a draft insessionStorage. The key is the database, the table, and either the row's primary key (edit) ornew(add). When the same dialog opens again, the draft is restored. A successful save or Cancel removes it. This is needed becausedata.vueonly renders the dialogsv-if="loggedIn", so they close as soon as the session is cleared, and signing in again is a full-page Keycloak redirect.sessionStorageis per tab, so drafts never outlive the tab and are not shared between tabs. Storage errors are caught and logged, so the dialog still works when storage is unavailable.dbrepo-ui/tests/axios-instance.test.ts, run with Bun's built-in runner (bun test ./tests/axios-instance.test.tsindbrepo-ui). No dependency is added. It covers a valid token, an expired token with three concurrent requests (one refresh, all requests get the new token), a token inside the margin, a failed refresh (no token, one toast) and a signed-out user. Against the old interceptor, 4 of the 5 cases fail.Evidence (local Compose stack, dbrepo realm access-token lifespan set to 60 s)
The stack ran the v1.14.1 backend images and a UI image built from this branch, or from
mainfor the "before" run. I signed in, opened a table's data page, waited past expiry without reloading, and added a row.Spring's
JwtTimestampValidatoraccepts tokens up to 60 s pastexpby default. A save about 9 s after expiry still succeeded onmain, so the 401 only shows up once a token is more than 60 s pastexp. The failures in the issue were 69 to 107 s past expiry.main(before)expPOST …/data→ 401, no/api/_auth/*call. Data Service:Jwt expired at 2026-10-12T02:06:21ZGET /api/_auth/session→ 200, thenPOST …/data→ 201 with the new token. UI server:Session expiredthenSuccessfully refreshed tokenGET /api/_auth/session.GET …/access/oidc124testerandGET …/database/{id}both sent the same new token and returned 200kcadm … users/{id}/logout)GET /api/_auth/sessionfor the whole burst. UI server:Token refresh failed: Error: invalid_grant: Session not active. One toast, header shows Login, requests are sent without a token (access/…→ 401, public reads → 200). There is no redirectAuthorizationheader and no/api/_auth/*calls from the interceptor. Public table data → 200{"id":5,"note":"draft survives sign-in"}was insessionStoragewhile typing. After the Keycloak sessions were revoked, the save → 401, the toast appeared, the dialog closed, and the draft was still stored. After signing in again and reopening Add on the table, the fields were prefilled. Create → 201, and the draft was removed…:[5]) survived a full page reload and was restored when Edit was reopened on the same rowThe request tokens were observed with an
XMLHttpRequest.setRequestHeaderhook in the browser, which logged only the last 10 characters of each token.Type of Change
Related Issue
Closes #124
Checklist
Additional Notes
Sign-in returns to the start page
After signing in again, users land on
/and have to go back to the table. Their draft is restored when they reopen the dialog. I tried passinglogin(undefined, { callbackRedirectUrl: route.fullPath }), and the gateway log shows the parameter reaching/auth/keycloak/login. However, the installednuxt-oidc-auth1.0.0-beta.10 callssession.clear()in its callback handler before readingsession.data.callbackRedirectUrl, so the value is lost. 1.0.0-beta.12 reads it before clearing. Upgrading the dependency and returning users to the page they started from belongs in a separate PR, so this PR does not change the login button.Not tested / limits
exp. A browser clock more than about 30 s behind can still send a token that the backend rejects, because nothing retries on a 401 response.expirationThresholdbelow 30 s, the session endpoint may return the same still-valid token, and the client will call it again on each request until the token really expires. That is still correct, but chattier.EditTuple.vuehas no automated test, because the UI has no component-test setup. It was verified in the browser only, as shown above.bun run build(what the CIuijob runs) and the new test locally with Bun 1.4.3. There is no lint or type-check step for the UI in CI, and I did not runnuxi typecheck.loggedInwithout.valuein script code:pages/me/index.vue,pages/account/index.vueandlayouts/default.vue. They don't go through the interceptor, so I left them out of this PR.