Skip to content

fix(ui): refresh expired OIDC tokens before API requests - #128

Open
satriadhikara wants to merge 1 commit into
mainfrom
fix/ui-token-refresh
Open

satriadhikara wants to merge 1 commit into
mainfrom
fix/ui-token-refresh

Conversation

@satriadhikara

Copy link
Copy Markdown
Collaborator

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.ts destructured canRefresh from useOidcAuth(). In nuxt-oidc-auth 1.0.0-beta.10 that field only exists on user.value, so the refresh branch never ran. The interceptor also tested !loggedIn instead of !loggedIn.value (a ComputedRef is always truthy). The token-expiry helpers in the file were unused and referenced jwtDecode/Token without importing them.

Change

  • Read loggedIn.value and user.value.expireAt. expireAt is the access token's exp in 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.
  • Refresh when the token expires within 30 seconds, to tolerate clock skew and request latency.
  • Refresh through useOidcAuth().fetch() (GET /api/_auth/session). With expirationCheck and automaticRefresh enabled, that endpoint refreshes the token server-side. The Keycloak preset sets expirationThreshold: 240, so it always refreshes when the client asks. If the refresh fails, it clears the session and returns {}. I did not use refresh() because it navigates to the logout URL when the refresh fails, which throws away whatever the user was editing.
  • Await the refresh before reading user.value.accessToken. Concurrent requests share one pending refresh promise.
  • If the session is gone after the refresh, show one toast, "Your session has expired. Please sign in again." (new key 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 because loggedIn becomes false.
  • Keep unsaved input in the add/edit row dialog (components/dialogs/EditTuple.vue). While the user types, the row is saved as a draft in sessionStorage. The key is the database, the table, and either the row's primary key (edit) or new (add). When the same dialog opens again, the draft is restored. A successful save or Cancel removes it. This is needed because data.vue only renders the dialogs v-if="loggedIn", so they close as soon as the session is cleared, and signing in again is a full-page Keycloak redirect. sessionStorage is 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.
  • Add dbrepo-ui/tests/axios-instance.test.ts, run with Bun's built-in runner (bun test ./tests/axios-instance.test.ts in dbrepo-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 main for the "before" run. I signed in, opened a table's data page, waited past expiry without reloading, and added a row.

Spring's JwtTimestampValidator accepts tokens up to 60 s past exp by default. A save about 9 s after expiry still succeeded on main, so the 401 only shows up once a token is more than 60 s past exp. The failures in the issue were 69 to 107 s past expiry.

Case main (before) This branch (after)
Save a row more than 60 s after exp 2 × POST …/data → 401, no /api/_auth/* call. Data Service: Jwt expired at 2026-10-12T02:06:21Z 1 × GET /api/_auth/session → 200, then POST …/data → 201 with the new token. UI server: Session expired then Successfully refreshed token
Burst of authenticated requests after expiry (client-side navigation to the database page) – 1 × GET /api/_auth/session. GET …/access/oidc124tester and GET …/database/{id} both sent the same new token and returned 200
Refresh fails (Keycloak sessions revoked with kcadm … users/{id}/logout) – 1 × GET /api/_auth/session for 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 redirect
Signed out – No Authorization header and no /api/_auth/* calls from the interceptor. Public table data → 200
Unsaved row when the refresh fails The dialog stayed open, but every save returned 401 until a reload, and the reload lost the input The draft {"id":5,"note":"draft survives sign-in"} was in sessionStorage while 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
Draft housekeeping – Cancel removes the draft, and the next Add opens empty. An Edit draft (key …:[5]) survived a full page reload and was restored when Edit was reopened on the same row

The request tokens were observed with an XMLHttpRequest.setRequestHeader hook in the browser, which logged only the last 10 characters of each token.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes, no API changes)
  • Dependency update

Related Issue

Closes #124

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

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 passing login(undefined, { callbackRedirectUrl: route.fullPath }), and the gateway log shows the parameter reaching /auth/keycloak/login. However, the installed nuxt-oidc-auth 1.0.0-beta.10 calls session.clear() in its callback handler before reading session.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

  • I did not test Safari or Firefox, a real multi-hour session, or a large browser clock offset. The check uses the browser clock against the server-issued 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.
  • If a deployment lowers the Keycloak provider's expirationThreshold below 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.
  • The draft handling in EditTuple.vue has no automated test, because the UI has no component-test setup. It was verified in the browser only, as shown above.
  • Drafts cover only the add/edit row dialog. Other forms (create table, query editor, etc.) still lose input when the session ends.
  • I ran bun run build (what the CI ui job 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 run nuxi typecheck.
  • Other components still use loggedIn without .value in script code: pages/me/index.vue, pages/account/index.vue and layouts/default.vue. They don't go through the interceptor, so I left them out of this PR.

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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI sends expired OIDC tokens and fails to save table data with 401

1 participant