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
60 changes: 58 additions & 2 deletions appointment-booking/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -485,21 +485,77 @@ p {
.sign-in-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--layout-padding-large);
margin: var(--layout-margin-large) 0;
margin: var(--layout-margin-large) auto;
max-width: 32rem;
width: 100%;
box-sizing: border-box;
text-align: center;
}

.sign-in-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
gap: var(--layout-padding-medium);
width: fit-content;
max-width: 100%;
}

.sign-in-or {
display: flex;
align-items: center;
justify-content: center;
gap: var(--layout-padding-medium);
align-self: stretch;
margin: var(--layout-margin-small) 0;
font: var(--typography-bold-small-body);
color: var(--typography-color-secondary);
}

.sign-in-or::before,
.sign-in-or::after {
content: '';
flex: 1 1 0;
height: 1px;
background-color: var(--surface-color-border-default, #afb2b5);
}

.sign-in-learn-more {
display: inline-flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
max-width: 100%;
font: var(--typography-regular-small-body);
color: var(--typography-color-link);
text-decoration: none;
}

.sign-in-learn-more span {
text-decoration: underline;
}

.sign-in-external-icon {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
margin-top: 0.15em;
}

@media (max-width: 40rem) {
.layout-main {
padding: var(--layout-padding-medium);
}

.sign-in-panel {
margin: var(--layout-margin-medium) 0;
}

.booking-nav-row {
flex-wrap: wrap;
}
}

.login-next-copy {
Expand Down
51 changes: 36 additions & 15 deletions appointment-booking/app/auth/keycloak.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Keycloak login helpers for citizen booking (BC Services Card).
// Handles: start login, read tokens after redirect, reject non-BCSC, logout.
// Keycloak login helpers for citizen booking (BCSC or email OTP).
// Handles: start login, read tokens after redirect, reject disallowed IdPs, logout.

import Keycloak, { type KeycloakLoginOptions } from 'keycloak-js'

Expand All @@ -25,7 +25,7 @@ type TokenClaims = {
display_name?: string
}

// Thrown when Keycloak signed someone in with the wrong identity provider (not BCSC).
// Thrown when Keycloak signed someone in with a disallowed identity provider.
export class WrongIdpError extends Error {
readonly identityProvider: string

Expand Down Expand Up @@ -74,7 +74,7 @@ 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.
// Rebuilds the auth session after a refresh/redirect. Drops disallowed IdP sessions.
export function readAuthSessionFromStorage(): AuthSession | null {
const token = getFromSession(SessionKeys.KeyCloakToken)
if (!token) return null
Expand Down Expand Up @@ -123,17 +123,20 @@ export function writeAuthSession(session: AuthSession): void {
addToSession(SessionKeys.UserAccountType, session.loginSource)
}

function buildSessionFromKeycloak(kc: Keycloak): AuthSession {
function buildSessionFromKeycloak(kc: Keycloak, requestedIdpHint: string): AuthSession {
const token = kc.token || ''
const claims = token ? decodeTokenClaims(token) : {}
const claimedIdp = resolveIdentityProvider(claims)
// Prefer the token claim; if the OTP/BCSC IdP mapper has not set it yet, use the IdP we asked for.
const loginSource = claimedIdp || requestedIdpHint.trim().toLowerCase()

return {
token,
idToken: kc.idToken || '',
refreshToken: kc.refreshToken || '',
userFullName: resolveFullName(claims),
kcGuid: claims.sub || '',
loginSource: resolveIdentityProvider(claims),
loginSource,
}
}

Expand All @@ -142,14 +145,14 @@ function appLoginRedirectUri(): string {
return `${window.location.origin}/login`
}

// Where Keycloak must send the browser back after BCSC (this finishes the OAuth code exchange).
// Where Keycloak must send the browser back after IdP login (finishes the OAuth code exchange).
function appSigninCallbackUri(idpHint: string): string {
return `${window.location.origin}/signin/${idpHint}`
}

// First call: redirect to Keycloak/BCSC.
// First call: redirect to Keycloak with the chosen IdP (bcsc or otp).
// Second call (after return to /signin/:idpHint): finish login and return the session.
// Rejects any IdP other than BCSC.
// Rejects any IdP not in ALLOWED_BOOKING_IDPS.
export async function initKeycloakLogin(idpHint: string): Promise<AuthSession | null> {
if (loginInFlight) return loginInFlight

Expand All @@ -162,13 +165,26 @@ export async function initKeycloakLogin(idpHint: string): Promise<AuthSession |

// OAuth callback must return to this page so keycloak-js can finish the code exchange.
const callbackUri = appSigninCallbackUri(idpHint)
// True when Keycloak has redirected back with an auth code (not a silent SSO reuse).
const isOAuthReturn =
window.location.search.includes('code=') || window.location.hash.includes('code=')

// Force the chosen IdP (bcsc) and our callback URL on every Keycloak login redirect.
// Force the chosen IdP + a fresh login so an existing Keycloak SSO session cannot win.
const originalLogin = kc.login.bind(kc)
const loginWithRequestedIdp = () =>
originalLogin({
idpHint,
redirectUri: callbackUri,
prompt: 'login',
})

kc.login = (options?: KeycloakLoginOptions) => {
const next = options
? { ...options, idpHint, redirectUri: options.redirectUri || callbackUri }
: { idpHint, redirectUri: callbackUri }
const next: KeycloakLoginOptions = {
...(options || {}),
idpHint,
redirectUri: options?.redirectUri || callbackUri,
prompt: 'login',
}
return originalLogin(next)
}

Expand All @@ -183,9 +199,14 @@ export async function initKeycloakLogin(idpHint: string): Promise<AuthSession |
return null
}

const session = buildSessionFromKeycloak(kc)
const session = buildSessionFromKeycloak(kc, idpHint)
if (!isAllowedBookingIdp(session.loginSource)) {
// End the Keycloak SSO session so the next attempt can use BCSC.
// If SSO reused a disallowed session (no OAuth code yet), force a fresh IdP login.
if (!isOAuthReturn) {
await loginWithRequestedIdp()
return null
}

await kc.logout({ redirectUri: `${appLoginRedirectUri()}?error=idp` })
throw new WrongIdpError(session.loginSource || 'unknown')
}
Expand Down
13 changes: 8 additions & 5 deletions appointment-booking/app/auth/session-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ export const SessionKeys = {
UserFullName: 'USER_FULL_NAME',
UserKcId: 'USER_KC_ID',
UserAccountType: 'USER_ACCOUNT_TYPE',
// Set while redirecting to Keycloak; used to break browser-back loops on /signin.
KeycloakLoginRedirectPending: 'KEYCLOAK_LOGIN_REDIRECT_PENDING',
// 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.
// Cleared on logout and when discarding a bad/disallowed auth session.
export const AUTH_SESSION_KEYS = [
SessionKeys.KeyCloakToken,
SessionKeys.KeyCloakRefreshToken,
Expand All @@ -22,20 +24,21 @@ export const AUTH_SESSION_KEYS = [
SessionKeys.UserAccountType,
] as const

// Cleared on logout only — kept across BCSC login redirect.
// Cleared on logout only — kept across IdP login redirect.
export const BOOKING_SESSION_KEYS = [
SessionKeys.BookingSelectedService,
SessionKeys.BookingSelectedLocation,
] as const

export const IdpHint = {
BCSC: 'bcsc',
OTP: 'otp',
} as const

// Citizen booking accepts only BCSC for now (OTP may be added later).
export const ALLOWED_BOOKING_IDPS = [IdpHint.BCSC] as const
// Citizen booking accepts BCSC or email OTP.
export const ALLOWED_BOOKING_IDPS = [IdpHint.BCSC, IdpHint.OTP] as const

// True only for IdPs we allow in this booking app (currently BCSC).
// True only for IdPs we allow in this booking app (BCSC or OTP).
export function isAllowedBookingIdp(identityProvider: string | null | undefined): boolean {
const normalized = identityProvider?.trim().toLowerCase()
return !!normalized && (ALLOWED_BOOKING_IDPS as readonly string[]).includes(normalized)
Expand Down
73 changes: 49 additions & 24 deletions appointment-booking/app/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Booking step 3: ask for BCSC sign-in, or show success after login.
import { useEffect, useState } from 'react'
// Booking step 3: BCSC or email OTP sign-in, or show success after login.
import { Button, Callout, InlineAlert, Text } from '@bcgov/design-system-react-components'
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useNavigate, useSearchParams } from 'react-router'

import { useAuth } from '~/auth/auth-context'
Expand All @@ -9,7 +10,6 @@ import { useBooking } from '~/booking/booking-context'
import { BookingBackRow } from '~/components/BookingBackRow'
import { BookingContinueRow } from '~/components/BookingContinueRow'
import { BookingStepProgress } from '~/components/BookingStepProgress'
import { getBCServicesCardUrl } from '~/runtime-config'

const BOOKING_STEP = 3
const BOOKING_STEP_COUNT = 5
Expand All @@ -24,13 +24,8 @@ export default function LoginPage() {
const { isReady: isAuthReady, isAuthenticated, session } = useAuth()
const { isReady: isBookingReady, selectedService, selectedLocation } = useBooking()
const hasSelections = !!selectedService && !!selectedLocation
const [bcscUrl, setBcscUrl] = useState('')
const idpError = searchParams.get('error') === 'idp'

useEffect(() => {
void getBCServicesCardUrl().then(setBcscUrl)
}, [])

// Wait until sessionStorage restore finishes so we do not flash the wrong screen.
if (!isAuthReady || !isBookingReady) {
return (
Expand Down Expand Up @@ -70,31 +65,61 @@ export default function LoginPage() {

{idpError ? (
<div className="login-alert">
<InlineAlert variant="danger" title="BC Services Card required">
This booking app only accepts BC Services Card sign-in. Please sign in again with BC
Services Card. Use a private browser window if you were signed in to Keycloak as IDIR.
<InlineAlert variant="danger" title="Sign-in method not accepted">
Please sign in with BC Services Card or email OTP.
</InlineAlert>
</div>
) : null}

<div className="sign-in-panel">
<Text>To continue your appointment booking, please sign in using BC Services Card.</Text>
<Text>
To continue your appointment booking, please sign in using one of the following methods.
</Text>

<div className="sign-in-actions">
<Button type="button" onPress={() => navigate(`/signin/${IdpHint.BCSC}`)}>
<Button
type="button"
onPress={() => navigate(`/signin/${IdpHint.OTP}`, { replace: true })}
>
Login with Email OTP
</Button>
<a
className="sign-in-learn-more"
href="https://www2.gov.bc.ca/gov/content/governments/services-for-government/information-management-technology/id-services/one-time-pc"
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="sign-in-external-icon"
aria-hidden="true"
/>
<span>Learn more about one-time passcode</span>
</a>

<div className="sign-in-or" role="separator" aria-label="or">
OR
</div>

<Button
type="button"
onPress={() => navigate(`/signin/${IdpHint.BCSC}`, { replace: true })}
>
Login with BC Services Card
</Button>

{bcscUrl ? (
<a
className="sign-in-learn-more"
href={bcscUrl}
target="_blank"
rel="noopener noreferrer"
>
Learn more about BC Services Card app
</a>
) : null}
<a
className="sign-in-learn-more"
href="https://www2.gov.bc.ca/gov/content/governments/government-id/bcservicescardapp"
target="_blank"
rel="noopener noreferrer"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="sign-in-external-icon"
aria-hidden="true"
/>
<span>Learn more about BC Services Card</span>
</a>
</div>
</div>

Expand Down
Loading
Loading