Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions appointment-booking/app/api/users.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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/`, {
Expand Down
21 changes: 20 additions & 1 deletion appointment-booking/app/auth/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthSession | null>(null)
const hasToken = !!session?.token

useEffect(() => {
// sessionStorage is browser-only, so restore it after the initial render.
Expand All @@ -24,18 +26,35 @@ 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)
setIsReady(true)
}, [])

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()
Expand All @@ -48,7 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
<AuthContext.Provider
value={{
isReady,
isAuthenticated: !!session?.token,
isAuthenticated: hasToken,
session,
setSession,
logout,
Expand Down
171 changes: 171 additions & 0 deletions appointment-booking/app/auth/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Proactive Keycloak access-token refresh while the tab stays open.
// Keycloak realm Access Token Lifespan is 5 minutes. We schedule one timer to fire
// ~30 seconds before that expiry, call updateToken(30), write the new tokens if
// refreshed, then schedule the next timer from the new expiry.

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'

// How early before access-token expiry we refresh (realm lifespan is 5 minutes).
const REFRESH_EARLY_SECONDS = 30

let refreshKc: Keycloak | undefined
let refreshTimerId: number | undefined
// Bumped on stop so a late ensure/refresh after logout/unmount does not reschedule.
let refreshRunId = 0
// Set by startTokenRefresh so getAccessToken failures clear React auth state too.
let onAuthSessionUpdated: ((session: AuthSession | null) => void) | null = null

async function ensureKeycloakFromStorage(): Promise<Keycloak | null> {
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<void> {
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<string> {
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')
}
}
Loading