-
Notifications
You must be signed in to change notification settings - Fork 56
Add BC Services Card sign-in to appointment booking #1093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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})`) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | ||
| } | ||
|
|
||
| // 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 }) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.