Refreshing OIDC Tokens in Long-Running GIS Sessions
This guide keeps long geospatial work authenticated across token expiry: an hour of digitising with no page load, a multi-gigabyte upload that outlives its access token, and a scheduled export that runs while nobody is present. It sits under Keycloak vs Dex for OIDC Federation, within the Core Portal Architecture & Security Boundaries framework.
Prerequisites
- A working OIDC integration, as set up in configuring a Keycloak realm for GeoNode SSO.
- Rights to change token lifetimes and client settings in the identity provider.
- A gateway that validates tokens per request and can return a distinguishable status on expiry.
- A test account and a browser session you can leave idle for the length of a token lifetime.
Three session shapes, three different answers
The mistake that produces most of the pain here is treating “keep the user logged in” as one problem. It is three, and only one of them is solved by refreshing a token.
The third column is the one that gets designed wrong in a hurry. Storing a user’s refresh token so a nightly job can act as them is expedient and creates a credential that outlives their employment, cannot be attributed at audit, and grants everything that person can do rather than what the job needs.
Step-by-step implementation
1. Set lifetimes from how the portal is used
# Access token: short, because it travels with every request and appears in logs.
kcadm.sh update realms/geoportal -s accessTokenLifespan=300
# Refresh token / SSO session idle: longer than a working session with a gap for
# lunch, so an editor is never bounced mid-task.
kcadm.sh update realms/geoportal \
-s ssoSessionIdleTimeout=43200 \
-s ssoSessionMaxLifespan=57600
# Rotate refresh tokens on use, and revoke the family if an old one is replayed.
kcadm.sh update realms/geoportal \
-s revokeRefreshToken=true \
-s refreshTokenMaxReuse=0
2. Refresh silently, before expiry, and only once
The client must refresh proactively rather than after a rejection, and it must ensure that a burst of parallel tile requests produces one refresh rather than thirty.
// auth.js — a single in-flight refresh, shared by every pending request.
let refreshing = null;
async function accessToken() {
const tok = readToken();
// Refresh at 75% of the lifetime, not at expiry: clocks drift and requests queue.
if (tok && tok.expiresAt - Date.now() > tok.lifetimeMs * 0.25) return tok.value;
// Collapse concurrent refreshes into one — a viewport of tiles must not
// trigger a refresh per tile, which is how a rotating refresh token gets
// replayed and the whole family revoked.
refreshing = refreshing || doRefresh().finally(() => { refreshing = null; });
return (await refreshing).value;
}
async function doRefresh() {
const res = await fetch("/auth/refresh", { method: "POST", credentials: "include" });
if (!res.ok) {
// Genuine expiry: preserve unsaved work before any navigation happens.
window.dispatchEvent(new CustomEvent("session-expiring"));
throw new Error("refresh failed");
}
return storeToken(await res.json());
}
3. Never lose the editor’s geometry
An expiry that discards unsaved edits is remembered long after the authentication detail is forgotten. Treat the expiry event as a UI concern first.
// editor.js — on the warning, save a local draft and prompt in place.
window.addEventListener("session-expiring", async () => {
const draft = editorState.serialiseUnsaved(); // GeoJSON, in local storage
localStorage.setItem("draft:" + location.pathname, JSON.stringify(draft));
showInlineReauthPrompt(); // an inline dialog, never a full-page redirect
});
4. Give unattended jobs their own identity
# A confidential client with the client-credentials grant: no user, no refresh
# token, and permissions scoped to what the job actually does.
kcadm.sh create clients -r geoportal \
-s clientId=nightly-export \
-s serviceAccountsEnabled=true \
-s standardFlowEnabled=false \
-s publicClient=false
# Grant only the roles the export needs — not the roles of the person who
# happened to request the export.
kcadm.sh add-roles -r geoportal --uusername service-account-nightly-export \
--rolename export_reader
What a rotating refresh token does to a tiled client
Refresh-token rotation is a real security improvement and it interacts badly with the request pattern a map produces, if the client is naive about it.
Validate locally, not with a call per request
How the gateway checks a token decides what an identity-provider outage does to the portal. Local validation against cached signing keys keeps existing sessions working when the provider is unreachable; introspection asks the provider on every request and turns its outage into yours.
Immediate revocation matters for a small number of high-value operations rather than for tile traffic. A workable split is local validation everywhere, with introspection reserved for write operations and administrative actions, so the expensive check is paid only where its guarantee is worth the dependency.
One further consideration applies to portals that broker several upstream identity providers. The effective session length is the shortest of the chain — the portal’s own session, the broker’s, and the upstream directory’s — and the one that ends first is usually the one nobody configured. Check all three when a user reports being logged out earlier than the settings suggest, and record which of them is authoritative for this portal so the next investigation starts in the right place.
Verification
# 1. An access token expires when configured to
TOKEN=$(curl -s -d "client_id=geonode" -d "username=$TEST_USER" -d "password=$TEST_PASS" \
-d "grant_type=password" "$ISSUER/protocol/openid-connect/token" | jq -r .access_token)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.exp - .iat'
# expect: 300
# 2. A refresh returns a NEW refresh token (rotation is on)
R1=$(curl -s -d "client_id=geonode" -d "grant_type=refresh_token" -d "refresh_token=$REFRESH" \
"$ISSUER/protocol/openid-connect/token" | jq -r .refresh_token)
test "$R1" != "$REFRESH" && echo "rotating"
# 3. Replaying the old refresh token is refused
curl -s -o /dev/null -w '%{http_code}\n' -d "client_id=geonode" -d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH" "$ISSUER/protocol/openid-connect/token"
# expect: 400 — and the family is revoked, which is the intended behaviour
# 4. The service account can obtain a token with no user involved
curl -s -d "client_id=nightly-export" -d "client_secret=$EXPORT_SECRET" \
-d "grant_type=client_credentials" "$ISSUER/protocol/openid-connect/token" | jq -r .access_token | cut -c1-12
# expect: a token, and its roles limited to export_reader
# 5. An idle browser session survives a full access-token lifetime
# Leave the editor idle for 6 minutes, then draw a feature.
# expect: the edit saves, with no redirect and no lost geometry
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| Users are logged out during heavy map use | Parallel requests each triggering a refresh, tripping replay detection | Collapse concurrent refreshes into one shared promise |
| An upload fails at a consistent duration | The access token expires mid-request | Take a fresh token immediately before starting, or chunk the upload |
| A nightly job stopped after a colleague left | It used that person’s refresh token | Move it to a service identity with its own scoped roles |
| Editors lose unsaved geometry on expiry | Expiry handled as a page redirect | Save a local draft on the warning, and re-authenticate in place |
| Refresh succeeds but the API still returns 401 | The gateway caches the old token’s validation result | Key the validation cache on the token, and respect its expiry |
| Sessions end abruptly at a fixed time of day | The maximum session lifespan, not the idle timeout | Raise the maximum, or accept it and warn the user before it arrives |
| Refresh fails only for users on one identity provider | Clock skew between the broker and that upstream | Synchronise clocks and allow a small leeway in validation |
FAQ
How short can an access token be without causing problems?
Minutes is comfortable for a portal whose clients refresh properly; the practical floor is set by how much refresh traffic the identity provider will see, since every client refreshing every five minutes is a real load. Five minutes is a reasonable default, and the number matters far less than whether the client refreshes proactively.
Should refresh tokens be stored in the browser?
Not in a place JavaScript can read. Keep the refresh token in an HTTP-only, same-site cookie handled by a small server-side endpoint, and let the browser hold only the short-lived access token. That way a script injection cannot exfiltrate the long-lived credential.
What about desktop GIS clients that only understand basic authentication?
Give them a scoped credential of their own rather than trying to make them speak OIDC — an API key as described in issuing scoped API keys for OGC clients is usually the pragmatic answer, with the scope narrowed to the layers that client needs.
Is refresh-token rotation worth the extra client complexity?
Yes, provided the client collapses concurrent refreshes. Rotation turns a stolen refresh token into a detectable event rather than a silent long-lived compromise, and the client-side change is a few lines. Without the collapsing, rotation will produce mysterious logouts under load and will be turned off — which is the worst of both outcomes.
How should the portal behave when the identity provider is unreachable?
Existing sessions with unexpired access tokens should continue working, which they will if validation uses cached signing keys rather than an introspection call per request. New logins and refreshes will fail, and that is correct. Choosing local validation over introspection is what turns an identity outage into a degraded service rather than a total one.
Related
- Configuring a Keycloak Realm for GeoNode SSO — the realm and client this builds on.
- Mapping OIDC Group Claims to GeoNode Roles — what the refreshed token carries.
- Issuing Scoped API Keys for OGC Clients — the alternative for clients that cannot refresh.
Up one level: Keycloak vs Dex for OIDC Federation.