From 93a412f7ed7ef63b211570245ae5ed71463e6493 Mon Sep 17 00:00:00 2001 From: Veenu Punyani Date: Thu, 23 Jul 2026 14:10:19 -0700 Subject: [PATCH 1/2] Added Keycloak access-token refresh Schedule updateToken near expiry, refresh before protected API calls via getAccessToken, and clear the auth session when refresh fails or if there is corrupt refreshtoken --- appointment-booking/app/api/users.ts | 8 +- appointment-booking/app/auth/auth-context.tsx | 21 ++- appointment-booking/app/auth/token-refresh.ts | 175 ++++++++++++++++++ 3 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 appointment-booking/app/auth/token-refresh.ts diff --git a/appointment-booking/app/api/users.ts b/appointment-booking/app/api/users.ts index 72ef1b9cc..34c83f7de 100644 --- a/appointment-booking/app/api/users.ts +++ b/appointment-booking/app/api/users.ts @@ -1,13 +1,9 @@ import { getApiBaseUrl } from '../runtime-config' -import { getFromSession } from '../auth/session' -import { SessionKeys } from '../auth/session-keys' +import { getAccessToken } from '../auth/token-refresh' // Tells the API "this Keycloak user exists" after a successful login. export async function createUser(): Promise { - const token = getFromSession(SessionKeys.KeyCloakToken) - if (!token) { - throw new Error('Cannot create user without an access token') - } + const token = await getAccessToken() const baseUrl = await getApiBaseUrl() const res = await fetch(`${baseUrl}/users/`, { diff --git a/appointment-booking/app/auth/auth-context.tsx b/appointment-booking/app/auth/auth-context.tsx index c95f83696..2519a115d 100644 --- a/appointment-booking/app/auth/auth-context.tsx +++ b/appointment-booking/app/auth/auth-context.tsx @@ -9,11 +9,13 @@ import { writeAuthSession, } from './keycloak' import { clearStoredBookingSession } from './session' +import { startTokenRefresh, stopTokenRefresh } from './token-refresh' import { AuthContext } from './auth-store' export function AuthProvider({ children }: { children: ReactNode }) { const [isReady, setIsReady] = useState(false) const [session, setSessionState] = useState(null) + const hasToken = !!session?.token useEffect(() => { // sessionStorage is browser-only, so restore it after the initial render. @@ -24,11 +26,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => window.clearTimeout(id) }, []) + // Start proactive refresh while signed in; stop on logout / unmount. + // Depend on hasToken (not the token string) so a refresh does not restart the loop. + useEffect(() => { + if (!hasToken) { + stopTokenRefresh() + return + } + + void startTokenRefresh((next) => { + setSessionState(next) + }) + + return () => stopTokenRefresh() + }, [hasToken]) + // Stable callbacks — /signin effect depends on setSession and must not re-run mid-login. const setSession = useCallback((next: AuthSession | null) => { if (next) { writeAuthSession(next) } else { + stopTokenRefresh() clearStoredAuthSession() } setSessionState(next) @@ -36,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []) const logout = useCallback(async () => { + stopTokenRefresh() const logoutPromise = logoutKeycloak(`${window.location.origin}/services`) // Logout ends the booking attempt too — do not leave service/location for the next user. clearStoredAuthSession() @@ -48,7 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { void) | null = null + +async function ensureKeycloakFromStorage(): Promise { + const token = getFromSession(SessionKeys.KeyCloakToken) || undefined + const refreshToken = getFromSession(SessionKeys.KeyCloakRefreshToken) || undefined + const idToken = getFromSession(SessionKeys.KeyCloakIdToken) || undefined + if (!token || !refreshToken) return null + if (refreshKc?.authenticated && refreshKc.token) return refreshKc + + const kc = new Keycloak(await getKeycloakConfigUrl()) + refreshKc = kc + const authenticated = await kc.init({ + token, + refreshToken, + idToken, + checkLoginIframe: false, + pkceMethod: 'S256', + }) + if (!authenticated || !kc.token) return null + return kc +} + +function sessionFromKeycloakTokens(kc: Keycloak): AuthSession { + return { + token: kc.token || '', + idToken: kc.idToken || '', + refreshToken: kc.refreshToken || '', + userFullName: getFromSession(SessionKeys.UserFullName) || '', + kcGuid: getFromSession(SessionKeys.UserKcId) || '', + loginSource: getFromSession(SessionKeys.UserAccountType) || '', + } +} + +function persistRefreshedSession(kc: Keycloak): void { + const session = sessionFromKeycloakTokens(kc) + writeAuthSession(session) + onAuthSessionUpdated?.(session) +} + +function clearRefreshTimer(): void { + if (refreshTimerId !== undefined) { + window.clearTimeout(refreshTimerId) + refreshTimerId = undefined + } +} + +export function stopTokenRefresh(): void { + refreshRunId += 1 + clearRefreshTimer() + // Drop the in-memory client so the next ensure re-reads sessionStorage tokens. + refreshKc = undefined +} + +function failRefresh(): void { + stopTokenRefresh() + clearStoredAuthSession() + onAuthSessionUpdated?.(null) +} + +// updateToken uses in-memory tokens; keep them aligned with sessionStorage (e.g. after DevTools edits). +function syncTokensFromStorage(kc: Keycloak): boolean { + const token = getFromSession(SessionKeys.KeyCloakToken) || undefined + const refreshToken = getFromSession(SessionKeys.KeyCloakRefreshToken) || undefined + const idToken = getFromSession(SessionKeys.KeyCloakIdToken) || undefined + if (!token || !refreshToken) return false + kc.token = token + kc.refreshToken = refreshToken + if (idToken) kc.idToken = idToken + return true +} + +function scheduleRefresh(kc: Keycloak, runId: number): void { + if (runId !== refreshRunId) return + + clearRefreshTimer() + + const exp = kc.tokenParsed?.exp + const timeSkew = kc.timeSkew + if (exp == null || timeSkew == null) { + failRefresh() + return + } + + // Seconds left on the 5-minute access token, adjusted for Keycloak clock skew. + const expiresInSec = exp - Math.ceil(Date.now() / 1000) + timeSkew + const delayMs = Math.max(0, (expiresInSec - REFRESH_EARLY_SECONDS) * 1000) + + refreshTimerId = window.setTimeout(() => { + void (async () => { + if (runId !== refreshRunId) return + try { + if (!syncTokensFromStorage(kc)) { + failRefresh() + return + } + const refreshed = await kc.updateToken(REFRESH_EARLY_SECONDS) + // Persist before the generation check so a remount/stop during await cannot drop tokens. + if (refreshed) persistRefreshedSession(kc) + if (runId !== refreshRunId) return + scheduleRefresh(kc, runId) + } catch { + // Keycloak returns 400 for a bad refresh token and rejects updateToken. + // Always clear app auth — do not skip when runId changed mid-request (Strict Mode / remount). + failRefresh() + } + })() + }, delayMs) +} + +export async function startTokenRefresh( + onUpdated: (session: AuthSession | null) => void, +): Promise { + stopTokenRefresh() + onAuthSessionUpdated = onUpdated + const runId = refreshRunId + + const kc = await ensureKeycloakFromStorage() + if (runId !== refreshRunId) return + if (!kc) { + failRefresh() + return + } + + scheduleRefresh(kc, runId) +} + +// For protected API calls: refresh if needed, then return a usable access token. +// On any failure, clear auth the same way the timer does, then throw a consistent error. +export async function getAccessToken(): Promise { + try { + const kc = await ensureKeycloakFromStorage() + if (!kc?.token) { + throw new Error('Session expired') + } + + if (!syncTokensFromStorage(kc)) { + throw new Error('Session expired') + } + + const refreshed = await kc.updateToken(REFRESH_EARLY_SECONDS) + if (refreshed) persistRefreshedSession(kc) + + if (!kc.token) { + throw new Error('Session expired') + } + return kc.token + } catch { + failRefresh() + throw new Error('Session expired') + } +} From 00d7bb518b07dff3d28dc803adac6b5b7a99eb58 Mon Sep 17 00:00:00 2001 From: Veenu Punyani Date: Thu, 23 Jul 2026 14:47:14 -0700 Subject: [PATCH 2/2] prettified --- appointment-booking/app/auth/token-refresh.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/appointment-booking/app/auth/token-refresh.ts b/appointment-booking/app/auth/token-refresh.ts index f994fb34d..a01e1e179 100644 --- a/appointment-booking/app/auth/token-refresh.ts +++ b/appointment-booking/app/auth/token-refresh.ts @@ -8,11 +8,7 @@ import Keycloak from 'keycloak-js' import { getKeycloakConfigUrl } from '../runtime-config' import { getFromSession } from './session' import { SessionKeys } from './session-keys' -import { - type AuthSession, - clearStoredAuthSession, - writeAuthSession, -} from './keycloak' +import { type AuthSession, clearStoredAuthSession, writeAuthSession } from './keycloak' // How early before access-token expiry we refresh (realm lifespan is 5 minutes). const REFRESH_EARLY_SECONDS = 30