diff --git a/appointment-booking/app/app.css b/appointment-booking/app/app.css index 2d79b4913..6fc769ef1 100644 --- a/appointment-booking/app/app.css +++ b/appointment-booking/app/app.css @@ -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 { diff --git a/appointment-booking/app/auth/keycloak.ts b/appointment-booking/app/auth/keycloak.ts index d496b317f..83c05506c 100644 --- a/appointment-booking/app/auth/keycloak.ts +++ b/appointment-booking/app/auth/keycloak.ts @@ -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' @@ -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 @@ -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 @@ -123,9 +123,12 @@ 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, @@ -133,7 +136,7 @@ function buildSessionFromKeycloak(kc: Keycloak): AuthSession { refreshToken: kc.refreshToken || '', userFullName: resolveFullName(claims), kcGuid: claims.sub || '', - loginSource: resolveIdentityProvider(claims), + loginSource, } } @@ -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 { if (loginInFlight) return loginInFlight @@ -162,13 +165,26 @@ export async function initKeycloakLogin(idpHint: string): Promise { - 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) } @@ -183,9 +199,14 @@ export async function initKeycloakLogin(idpHint: string): Promise { - void getBCServicesCardUrl().then(setBcscUrl) - }, []) - // Wait until sessionStorage restore finishes so we do not flash the wrong screen. if (!isAuthReady || !isBookingReady) { return ( @@ -70,31 +65,61 @@ export default function LoginPage() { {idpError ? (
- - 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. + + Please sign in with BC Services Card or email OTP.
) : null}
- To continue your appointment booking, please sign in using BC Services Card. + + To continue your appointment booking, please sign in using one of the following methods. +
- + + + +
+ OR +
+ + - - {bcscUrl ? ( - - Learn more about BC Services Card app - - ) : null} + +
diff --git a/appointment-booking/app/routes/signin.$idpHint.tsx b/appointment-booking/app/routes/signin.$idpHint.tsx index f8d815a51..7cf1f0735 100644 --- a/appointment-booking/app/routes/signin.$idpHint.tsx +++ b/appointment-booking/app/routes/signin.$idpHint.tsx @@ -1,4 +1,4 @@ -// OAuth return page after Keycloak/BCSC. +// OAuth return page after Keycloak (BCSC or email OTP). // Not a user-facing step — it finishes login, then sends the user back to /login. import { useEffect, useState } from 'react' import { Button, InlineAlert, Text } from '@bcgov/design-system-react-components' @@ -7,7 +7,8 @@ import { useNavigate, useParams } from 'react-router' import { createUser } from '~/api/users' import { useAuth } from '~/auth/auth-context' import { initKeycloakLogin, WrongIdpError } from '~/auth/keycloak' -import { isAllowedBookingIdp } from '~/auth/session-keys' +import { addToSession, getFromSession, removeFromSession } from '~/auth/session' +import { isAllowedBookingIdp, SessionKeys } from '~/auth/session-keys' export function meta() { return [{ title: 'Signing in' }] @@ -22,15 +23,43 @@ export default function SigninCallbackPage() { useEffect(() => { let cancelled = false + // Browser back can restore this page without re-running login. Send them to /login. + function onPageShow(event: PageTransitionEvent) { + const hasCode = + window.location.search.includes('code=') || window.location.hash.includes('code=') + if (event.persisted && !hasCode) { + removeFromSession(SessionKeys.KeycloakLoginRedirectPending) + window.location.replace('/login') + } + } + + window.addEventListener('pageshow', onPageShow) + async function run() { - // Only BCSC is allowed to start login from this route. + // Only allowed booking IdPs (bcsc, otp) may start login from this route. if (!idpHint || !isAllowedBookingIdp(idpHint)) { + removeFromSession(SessionKeys.KeycloakLoginRedirectPending) navigate('/login?error=idp', { replace: true }) return } + // Browser back from Keycloak lands here without an OAuth code. Without this guard, + // login-required redirects to Keycloak again and the page appears to keep refreshing. + const hasOAuthCode = + window.location.search.includes('code=') || window.location.hash.includes('code=') + + if (!hasOAuthCode) { + if (getFromSession(SessionKeys.KeycloakLoginRedirectPending) === idpHint) { + removeFromSession(SessionKeys.KeycloakLoginRedirectPending) + navigate('/login', { replace: true }) + return + } + addToSession(SessionKeys.KeycloakLoginRedirectPending, idpHint) + } + try { const session = await initKeycloakLogin(idpHint) + removeFromSession(SessionKeys.KeycloakLoginRedirectPending) if (cancelled) return if (!session) { @@ -46,6 +75,7 @@ export default function SigninCallbackPage() { navigate('/login', { replace: true }) } catch (err) { + removeFromSession(SessionKeys.KeycloakLoginRedirectPending) if (cancelled) return if (err instanceof WrongIdpError) { // Keycloak logout redirects to the login page with the IdP error. @@ -60,6 +90,7 @@ export default function SigninCallbackPage() { return () => { cancelled = true + window.removeEventListener('pageshow', onPageShow) } // Only re-run when the IdP changes. setSession/navigate are stable enough for this page. }, [idpHint]) // eslint-disable-line react-hooks/exhaustive-deps @@ -70,7 +101,11 @@ export default function SigninCallbackPage() { {error} - diff --git a/appointment-booking/app/runtime-config.ts b/appointment-booking/app/runtime-config.ts index 582eb6e14..18558060a 100644 --- a/appointment-booking/app/runtime-config.ts +++ b/appointment-booking/app/runtime-config.ts @@ -4,7 +4,6 @@ type RuntimeConfig = { VUE_APP_ROOT_API?: string KEYCLOAK_CONFIG_URL?: string - BC_SERVICES_CARD_URL?: string } const DEFAULT_API_BASE_URL = '/api/v1' @@ -56,8 +55,3 @@ export async function getKeycloakConfigUrl(): Promise { const config = await loadRuntimeConfig() return config.KEYCLOAK_CONFIG_URL?.trim() || DEFAULT_KEYCLOAK_CONFIG_URL } - -export async function getBCServicesCardUrl(): Promise { - const config = await loadRuntimeConfig() - return config.BC_SERVICES_CARD_URL?.trim() || '' -} diff --git a/appointment-booking/public/config/configuration.json b/appointment-booking/public/config/configuration.json index 676f42936..b7f09f5f4 100644 --- a/appointment-booking/public/config/configuration.json +++ b/appointment-booking/public/config/configuration.json @@ -1,5 +1,4 @@ { "VUE_APP_ROOT_API": "http://localhost:5000/api/v1", - "KEYCLOAK_CONFIG_URL": "/config/kc/keycloak-public.json", - "BC_SERVICES_CARD_URL": "https://www2.gov.bc.ca/gov/content?id=B2B3A21E797A421A8FD39EEA86E245D6" + "KEYCLOAK_CONFIG_URL": "/config/kc/keycloak-public.json" }