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
25 changes: 25 additions & 0 deletions appointment-booking/app/api/users.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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})`)
}
}
40 changes: 40 additions & 0 deletions appointment-booking/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
68 changes: 68 additions & 0 deletions appointment-booking/app/auth/auth-context.tsx
Original file line number Diff line number Diff line change
@@ -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<AuthSession | null>(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 (
<AuthContext.Provider
value={{
isReady,
isAuthenticated: !!session?.token,
session,
setSession,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}

export function useAuth() {
const value = useContext(AuthContext)
if (!value) {
throw new Error('useAuth must be used within AuthProvider')
}
return value
}
14 changes: 14 additions & 0 deletions appointment-booking/app/auth/auth-store.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

// Isolated from component exports so Vite/Fast Refresh cannot duplicate this context.
export const AuthContext = createContext<AuthContextValue | null>(null)
227 changes: 227 additions & 0 deletions appointment-booking/app/auth/keycloak.ts
Original file line number Diff line number Diff line change
@@ -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<AuthSession | null> | 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'
}
Comment thread
chrsamp marked this conversation as resolved.

// 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<AuthSession | null> {
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<void> {
// 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 })
}
Loading
Loading