diff --git a/appointment-booking/app/api/users.ts b/appointment-booking/app/api/users.ts new file mode 100644 index 000000000..72ef1b9cc --- /dev/null +++ b/appointment-booking/app/api/users.ts @@ -0,0 +1,25 @@ +import { getApiBaseUrl } from '../runtime-config' +import { getFromSession } from '../auth/session' +import { SessionKeys } from '../auth/session-keys' + +// 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 baseUrl = await getApiBaseUrl() + const res = await fetch(`${baseUrl}/users/`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + if (!res.ok) { + throw new Error(`Failed to create user (${res.status})`) + } +} diff --git a/appointment-booking/app/app.css b/appointment-booking/app/app.css index d055ae576..2d79b4913 100644 --- a/appointment-booking/app/app.css +++ b/appointment-booking/app/app.css @@ -481,3 +481,43 @@ p { padding-top: 0; } } + +.sign-in-panel { + display: flex; + flex-direction: column; + gap: var(--layout-padding-large); + margin: var(--layout-margin-large) 0; + max-width: 32rem; +} + +.sign-in-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--layout-padding-medium); +} + +.sign-in-learn-more { + font: var(--typography-regular-small-body); + color: var(--typography-color-link); +} + +.login-next-copy { + margin: var(--layout-margin-large) 0; +} + +.login-alert { + margin-bottom: var(--layout-margin-large); +} + +.header-account { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--layout-padding-medium); + /* Header background is light — use primary text, not invert. */ + color: var(--typography-color-primary); + font: var(--typography-regular-small-body); + text-align: right; +} diff --git a/appointment-booking/app/auth/auth-context.tsx b/appointment-booking/app/auth/auth-context.tsx new file mode 100644 index 000000000..c95f83696 --- /dev/null +++ b/appointment-booking/app/auth/auth-context.tsx @@ -0,0 +1,68 @@ +// Holds the signed-in user for the whole app (header + booking steps). +import { useCallback, useContext, useEffect, useState, type ReactNode } from 'react' + +import { + clearStoredAuthSession, + logoutKeycloak, + readAuthSessionFromStorage, + type AuthSession, + writeAuthSession, +} from './keycloak' +import { clearStoredBookingSession } from './session' +import { AuthContext } from './auth-store' + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isReady, setIsReady] = useState(false) + const [session, setSessionState] = useState(null) + + useEffect(() => { + // sessionStorage is browser-only, so restore it after the initial render. + const id = window.setTimeout(() => { + setSessionState(readAuthSessionFromStorage()) + setIsReady(true) + }, 0) + return () => window.clearTimeout(id) + }, []) + + // 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 { + clearStoredAuthSession() + } + setSessionState(next) + setIsReady(true) + }, []) + + const logout = useCallback(async () => { + const logoutPromise = logoutKeycloak(`${window.location.origin}/services`) + // Logout ends the booking attempt too — do not leave service/location for the next user. + clearStoredAuthSession() + clearStoredBookingSession() + setSessionState(null) + await logoutPromise + }, []) + + return ( + + {children} + + ) +} + +export function useAuth() { + const value = useContext(AuthContext) + if (!value) { + throw new Error('useAuth must be used within AuthProvider') + } + return value +} diff --git a/appointment-booking/app/auth/auth-store.ts b/appointment-booking/app/auth/auth-store.ts new file mode 100644 index 000000000..1b81c4f6c --- /dev/null +++ b/appointment-booking/app/auth/auth-store.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react' + +import type { AuthSession } from './keycloak' + +export type AuthContextValue = { + isReady: boolean + isAuthenticated: boolean + session: AuthSession | null + setSession: (session: AuthSession | null) => void + logout: () => Promise +} + +// Isolated from component exports so Vite/Fast Refresh cannot duplicate this context. +export const AuthContext = createContext(null) diff --git a/appointment-booking/app/auth/keycloak.ts b/appointment-booking/app/auth/keycloak.ts new file mode 100644 index 000000000..d496b317f --- /dev/null +++ b/appointment-booking/app/auth/keycloak.ts @@ -0,0 +1,227 @@ +// Keycloak login helpers for citizen booking (BC Services Card). +// Handles: start login, read tokens after redirect, reject non-BCSC, logout. + +import Keycloak, { type KeycloakLoginOptions } from 'keycloak-js' + +import { getKeycloakConfigUrl } from '../runtime-config' +import { addToSession, getFromSession, removeFromSession } from './session' +import { AUTH_SESSION_KEYS, isAllowedBookingIdp, SessionKeys } from './session-keys' + +// What the rest of the app treats as "signed in". +export type AuthSession = { + token: string + idToken: string + refreshToken: string + userFullName: string + kcGuid: string + loginSource: string +} + +type TokenClaims = { + email?: string + sub?: string + loginSource?: string + identity_provider?: string + display_name?: string +} + +// Thrown when Keycloak signed someone in with the wrong identity provider (not BCSC). +export class WrongIdpError extends Error { + readonly identityProvider: string + + constructor(identityProvider: string) { + super('WRONG_IDP') + this.name = 'WrongIdpError' + this.identityProvider = identityProvider + } +} + +// Reused on logout so we can call Keycloak logout with the current tokens. +let kcInstance: Keycloak | undefined + +// Prevents a second /signin mount from starting a new login while one is in progress +// (would burn the OAuth code and loop on "Signing you in…"). +let loginInFlight: Promise | null = null + +// Removes auth tokens/profile from sessionStorage. Does not touch booking selections. +export function clearStoredAuthSession(): void { + for (const key of AUTH_SESSION_KEYS) { + removeFromSession(key) + } +} + +// JWT middle section → JSON claims (name, IdP, user id, etc.). +function decodeTokenClaims(token: string): TokenClaims { + const base64Url = token.split('.')[1] + if (!base64Url) return {} + const base64 = decodeURIComponent( + window + .atob(base64Url) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join(''), + ) + return JSON.parse(base64) as TokenClaims +} + +// Which login method was used: bcsc, idir, bceid, ... +function resolveIdentityProvider(claims: TokenClaims): string { + return (claims.identity_provider || claims.loginSource || '').trim().toLowerCase() +} + +// Prefer display_name; then email; then "Appointment User". +function resolveFullName(claims: TokenClaims): string { + return claims.display_name?.trim() || claims.email?.trim() || 'Appointment User' +} + +// Rebuilds the auth session after a refresh/redirect. Drops non-BCSC sessions. +export function readAuthSessionFromStorage(): AuthSession | null { + const token = getFromSession(SessionKeys.KeyCloakToken) + if (!token) return null + + let identityProvider = getFromSession(SessionKeys.UserAccountType) || '' + let userFullName = getFromSession(SessionKeys.UserFullName) || '' + let kcGuid = getFromSession(SessionKeys.UserKcId) || '' + + try { + const claims = decodeTokenClaims(token) + if (!identityProvider) { + identityProvider = resolveIdentityProvider(claims) + } + if (!userFullName) { + userFullName = resolveFullName(claims) + } + if (!kcGuid) { + kcGuid = claims.sub || '' + } + } catch { + // Keep stored values if the token cannot be decoded. + } + + if (!isAllowedBookingIdp(identityProvider)) { + clearStoredAuthSession() + return null + } + + return { + token, + idToken: getFromSession(SessionKeys.KeyCloakIdToken) || '', + refreshToken: getFromSession(SessionKeys.KeyCloakRefreshToken) || '', + userFullName, + kcGuid, + loginSource: identityProvider, + } +} + +// Saves tokens + display fields so login survives the Keycloak redirect. +export function writeAuthSession(session: AuthSession): void { + addToSession(SessionKeys.KeyCloakToken, session.token) + addToSession(SessionKeys.KeyCloakIdToken, session.idToken) + addToSession(SessionKeys.KeyCloakRefreshToken, session.refreshToken) + addToSession(SessionKeys.UserFullName, session.userFullName) + addToSession(SessionKeys.UserKcId, session.kcGuid) + addToSession(SessionKeys.UserAccountType, session.loginSource) +} + +function buildSessionFromKeycloak(kc: Keycloak): AuthSession { + const token = kc.token || '' + const claims = token ? decodeTokenClaims(token) : {} + + return { + token, + idToken: kc.idToken || '', + refreshToken: kc.refreshToken || '', + userFullName: resolveFullName(claims), + kcGuid: claims.sub || '', + loginSource: resolveIdentityProvider(claims), + } +} + +function appLoginRedirectUri(): string { + // Always return to this environment's app (localhost / test / prod), not a hardcoded URL. + return `${window.location.origin}/login` +} + +// Where Keycloak must send the browser back after BCSC (this finishes the OAuth code exchange). +function appSigninCallbackUri(idpHint: string): string { + return `${window.location.origin}/signin/${idpHint}` +} + +// First call: redirect to Keycloak/BCSC. +// Second call (after return to /signin/:idpHint): finish login and return the session. +// Rejects any IdP other than BCSC. +export async function initKeycloakLogin(idpHint: string): Promise { + if (loginInFlight) return loginInFlight + + loginInFlight = (async () => { + clearStoredAuthSession() + + const keycloakConfigUrl = await getKeycloakConfigUrl() + const kc = new Keycloak(keycloakConfigUrl) + kcInstance = kc + + // OAuth callback must return to this page so keycloak-js can finish the code exchange. + const callbackUri = appSigninCallbackUri(idpHint) + + // Force the chosen IdP (bcsc) and our callback URL on every Keycloak login redirect. + const originalLogin = kc.login.bind(kc) + kc.login = (options?: KeycloakLoginOptions) => { + const next = options + ? { ...options, idpHint, redirectUri: options.redirectUri || callbackUri } + : { idpHint, redirectUri: callbackUri } + return originalLogin(next) + } + + const authenticated = await kc.init({ + onLoad: 'login-required', + checkLoginIframe: false, + pkceMethod: 'S256', + redirectUri: callbackUri, + }) + + if (!authenticated || !kc.token) { + return null + } + + const session = buildSessionFromKeycloak(kc) + if (!isAllowedBookingIdp(session.loginSource)) { + // End the Keycloak SSO session so the next attempt can use BCSC. + await kc.logout({ redirectUri: `${appLoginRedirectUri()}?error=idp` }) + throw new WrongIdpError(session.loginSource || 'unknown') + } + + return session + })().finally(() => { + loginInFlight = null + }) + + return loginInFlight +} + +export async function logoutKeycloak(redirectUri: string): Promise { + // Capture tokens before AuthProvider clears local session storage. + const token = getFromSession(SessionKeys.KeyCloakToken) || undefined + const refreshToken = getFromSession(SessionKeys.KeyCloakRefreshToken) || undefined + const idToken = getFromSession(SessionKeys.KeyCloakIdToken) || undefined + + if (!token) { + window.location.assign(redirectUri) + return + } + + const keycloakConfigUrl = await getKeycloakConfigUrl() + const kc = kcInstance || new Keycloak(keycloakConfigUrl) + kcInstance = kc + + if (!kc.authenticated) { + await kc.init({ + token, + refreshToken, + idToken, + checkLoginIframe: false, + pkceMethod: 'S256', + }) + } + + await kc.logout({ redirectUri }) +} diff --git a/appointment-booking/app/auth/session-keys.ts b/appointment-booking/app/auth/session-keys.ts new file mode 100644 index 000000000..ac46cff31 --- /dev/null +++ b/appointment-booking/app/auth/session-keys.ts @@ -0,0 +1,42 @@ +// Session keys and IdP hints + +export const SessionKeys = { + KeyCloakToken: 'KEYCLOAK_TOKEN', + KeyCloakRefreshToken: 'KEYCLOAK_REFRESH_TOKEN', + KeyCloakIdToken: 'KEYCLOAK_ID_TOKEN', + UserFullName: 'USER_FULL_NAME', + UserKcId: 'USER_KC_ID', + UserAccountType: 'USER_ACCOUNT_TYPE', + // Booking selections survive the IdP redirect round-trip. + BookingSelectedService: 'BOOKING_SELECTED_SERVICE', + BookingSelectedLocation: 'BOOKING_SELECTED_LOCATION', +} as const + +// Cleared on logout and when discarding a bad/non-BCSC auth session. +export const AUTH_SESSION_KEYS = [ + SessionKeys.KeyCloakToken, + SessionKeys.KeyCloakRefreshToken, + SessionKeys.KeyCloakIdToken, + SessionKeys.UserFullName, + SessionKeys.UserKcId, + SessionKeys.UserAccountType, +] as const + +// Cleared on logout only — kept across BCSC login redirect. +export const BOOKING_SESSION_KEYS = [ + SessionKeys.BookingSelectedService, + SessionKeys.BookingSelectedLocation, +] as const + +export const IdpHint = { + BCSC: 'bcsc', +} as const + +// Citizen booking accepts only BCSC for now (OTP may be added later). +export const ALLOWED_BOOKING_IDPS = [IdpHint.BCSC] as const + +// True only for IdPs we allow in this booking app (currently BCSC). +export function isAllowedBookingIdp(identityProvider: string | null | undefined): boolean { + const normalized = identityProvider?.trim().toLowerCase() + return !!normalized && (ALLOWED_BOOKING_IDPS as readonly string[]).includes(normalized) +} diff --git a/appointment-booking/app/auth/session.ts b/appointment-booking/app/auth/session.ts new file mode 100644 index 000000000..6f16422a7 --- /dev/null +++ b/appointment-booking/app/auth/session.ts @@ -0,0 +1,40 @@ +// Tiny sessionStorage helpers. +// Browser-only: if sessionStorage is missing (server render), do nothing so we don't crash. + +import { BOOKING_SESSION_KEYS } from './session-keys' + +export function getFromSession(key: string): string | null { + if (typeof sessionStorage === 'undefined') return null + return sessionStorage.getItem(key) +} + +export function addToSession(key: string, value: string): void { + if (typeof sessionStorage === 'undefined') return + sessionStorage.setItem(key, value) +} + +export function removeFromSession(key: string): void { + if (typeof sessionStorage === 'undefined') return + sessionStorage.removeItem(key) +} + +// Clears selected service/location. Used on logout only. +export function clearStoredBookingSession(): void { + for (const key of BOOKING_SESSION_KEYS) { + removeFromSession(key) + } +} + +export function getJsonFromSession(key: string): T | null { + const raw = getFromSession(key) + if (!raw) return null + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +export function addJsonToSession(key: string, value: unknown): void { + addToSession(key, JSON.stringify(value)) +} diff --git a/appointment-booking/app/booking/booking-context.tsx b/appointment-booking/app/booking/booking-context.tsx index d625957f6..eb1b86db8 100644 --- a/appointment-booking/app/booking/booking-context.tsx +++ b/appointment-booking/app/booking/booking-context.tsx @@ -1,36 +1,61 @@ -import { createContext, useContext, useState, type ReactNode } from 'react' +// Shared service + location selection across booking steps. +// Also saved to sessionStorage so selections survive the BCSC redirect. +import { useContext, useEffect, useState, type ReactNode } from 'react' import type { Location } from '../api/locations' import type { Service } from '../api/services' +import { addJsonToSession, getJsonFromSession, removeFromSession } from '../auth/session' +import { SessionKeys } from '../auth/session-keys' +import { BookingContext } from './booking-store' -// Booking-flow state shared across steps (in-memory; not page-refresh persistent). -// Mounted once in root so selection survives navigation between booking steps. -type BookingContextValue = { - // Full service object so later steps can use id/name/bookable without re-fetching. - selectedService: Service | null - setSelectedService: (service: Service | null) => void - // Full location object so later steps can use id/name/address without re-fetching. - selectedLocation: Location | null - setSelectedLocation: (location: Location | null) => void -} +export function BookingProvider({ children }: { children: ReactNode }) { + const [isReady, setIsReady] = useState(false) + const [selectedService, setSelectedServiceState] = useState(null) + const [selectedLocation, setSelectedLocationState] = useState(null) -const BookingContext = createContext(null) + useEffect(() => { + // sessionStorage is browser-only, so restore it after the initial render. + const id = window.setTimeout(() => { + setSelectedServiceState(getJsonFromSession(SessionKeys.BookingSelectedService)) + setSelectedLocationState(getJsonFromSession(SessionKeys.BookingSelectedLocation)) + setIsReady(true) + }, 0) + return () => window.clearTimeout(id) + }, []) -// Owns booking selections for the SPA session. Step pages remount; this provider does not. -export function BookingProvider({ children }: { children: ReactNode }) { - const [selectedService, setSelectedService] = useState(null) - const [selectedLocation, setSelectedLocation] = useState(null) + function setSelectedService(service: Service | null) { + setSelectedServiceState(service) + if (service) { + addJsonToSession(SessionKeys.BookingSelectedService, service) + } else { + removeFromSession(SessionKeys.BookingSelectedService) + } + } + + function setSelectedLocation(location: Location | null) { + setSelectedLocationState(location) + if (location) { + addJsonToSession(SessionKeys.BookingSelectedLocation, location) + } else { + removeFromSession(SessionKeys.BookingSelectedLocation) + } + } return ( {children} ) } -// Used by booking step pages (services, locations; time/etc. later). export function useBooking() { const value = useContext(BookingContext) if (!value) { diff --git a/appointment-booking/app/booking/booking-store.ts b/appointment-booking/app/booking/booking-store.ts new file mode 100644 index 000000000..bb76caee7 --- /dev/null +++ b/appointment-booking/app/booking/booking-store.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react' + +import type { Location } from '../api/locations' +import type { Service } from '../api/services' + +export type BookingContextValue = { + isReady: boolean + selectedService: Service | null + setSelectedService: (service: Service | null) => void + selectedLocation: Location | null + setSelectedLocation: (location: Location | null) => void +} + +// Isolated from component exports so Vite/Fast Refresh cannot duplicate this context. +export const BookingContext = createContext(null) diff --git a/appointment-booking/app/root.tsx b/appointment-booking/app/root.tsx index 883a84913..b8d347282 100644 --- a/appointment-booking/app/root.tsx +++ b/appointment-booking/app/root.tsx @@ -1,9 +1,10 @@ -import { Footer, Header } from '@bcgov/design-system-react-components' +import { Button, Footer, Header } from '@bcgov/design-system-react-components' import { config } from '@fortawesome/fontawesome-svg-core' import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' import type { Route } from './+types/root' -import { BookingProvider } from './booking/booking-context' +import { AuthProvider, useAuth } from '~/auth/auth-context' +import { BookingProvider } from '~/booking/booking-context' import '@bcgov/design-tokens/css/variables.css' import '@bcgov/bc-sans/css/BC_Sans.css' import './bcds-shell.css' @@ -12,6 +13,8 @@ import './app.css' config.autoAddCss = false export function Layout({ children }: { children: React.ReactNode }) { + // Document shell only — context providers must live in the root route component, + // not Layout, so they share the same React tree as route modules. return ( @@ -31,6 +34,18 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { + return ( + + + + + + ) +} + +function AppShell() { + const { isAuthenticated, session, logout } = useAuth() + return ( <>
, ]} - /> + > + {isAuthenticated ? ( +
+ + Signed in as {session?.userFullName?.trim() || 'Appointment User'} + + +
+ ) : null} +
- {/* Keeps booking selection alive while navigating between booking steps. */} - - - +