Skip to content

Commit 12363fa

Browse files
committed
✨ Forward login hints through Keycloak
1 parent c398861 commit 12363fa

3 files changed

Lines changed: 97 additions & 12 deletions

File tree

packages/voting-portal/src/App.tsx

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// SPDX-License-Identifier: AGPL-3.0-only
44

5-
import React, {useEffect, useContext, useMemo, useCallback} from "react"
5+
import React, {useEffect, useContext, useMemo, useCallback, useState} from "react"
66
import {Outlet, ScrollRestoration, useLocation, useParams} from "react-router-dom"
77
import {styled} from "@mui/material/styles"
88
import {Footer, Header, PageBanner} from "@sequentech/ui-essentials"
@@ -33,6 +33,7 @@ import WatermarkBackground from "./components/WaterMark/Watermark"
3333
import SequentLogo from "@sequentech/ui-essentials/public/Sequent_logo.svg"
3434
import BlankLogoImg from "@sequentech/ui-essentials/public/blank_logo.svg"
3535
import {useElectionClassName} from "./hooks/useElectionClassName"
36+
import {InvalidLoginHintsError, parseLoginHints} from "./utils/loginHints"
3637
interface ElectionEventConfigDocument {
3738
id: string
3839
tenant_id: string
@@ -132,6 +133,27 @@ const App = () => {
132133
const location = useLocation()
133134
const {tenantId, eventId} = useParams<TenantEventType>()
134135
const {isAuthenticated, setTenantEvent} = useContext(AuthContext)
136+
const [loginHintRequest] = useState(() => {
137+
const acceptsLoginHints = ["/login", "/enroll"].some((suffix) =>
138+
location.pathname.endsWith(suffix)
139+
)
140+
141+
try {
142+
const parsed = acceptsLoginHints
143+
? parseLoginHints(location.search)
144+
: {hints: {}, remainingSearch: location.search}
145+
return {...parsed, pathname: location.pathname, hash: location.hash}
146+
} catch (error) {
147+
if (error instanceof InvalidLoginHintsError) {
148+
throw new VotingPortalError(VotingPortalErrorType.INVALID_LOGIN_HINT_PARAMETERS)
149+
}
150+
throw error
151+
}
152+
})
153+
const loginHintsForCurrentRoute = useMemo(
154+
() => (loginHintRequest.pathname === location.pathname ? loginHintRequest.hints : {}),
155+
[location.pathname, loginHintRequest]
156+
)
135157

136158
const electionIds = useAppSelector(selectElectionIds)
137159
const ballotStyleElectionIds = useAppSelector(selectBallotStyleElectionIds)
@@ -144,6 +166,22 @@ const App = () => {
144166

145167
useElectionClassName()
146168

169+
useEffect(() => {
170+
if (Object.keys(loginHintRequest.hints).length === 0) {
171+
return
172+
}
173+
174+
// Keep validated hints in memory while removing PII from browser history and redirect URIs.
175+
navigate(
176+
{
177+
pathname: loginHintRequest.pathname,
178+
search: loginHintRequest.remainingSearch,
179+
hash: loginHintRequest.hash,
180+
},
181+
{replace: true}
182+
)
183+
}, [loginHintRequest, navigate])
184+
147185
useEffect(() => {
148186
if (location.pathname === "/") {
149187
throw new VotingPortalError(VotingPortalErrorType.NO_ELECTION_EVENT)
@@ -185,12 +223,19 @@ const App = () => {
185223
? languageConf.default_language_code
186224
: undefined
187225

188-
setTenantEvent(tenantId, eventId, mode, defaultLocale)
226+
setTenantEvent(tenantId, eventId, mode, defaultLocale, loginHintsForCurrentRoute)
189227
} catch (error) {
190228
console.error("Error loading election event config:", error)
191-
setTenantEvent(tenantId, eventId, mode, undefined)
229+
setTenantEvent(tenantId, eventId, mode, undefined, loginHintsForCurrentRoute)
192230
}
193-
}, [tenantId, eventId, electionEventConfigUrl, location.pathname, setTenantEvent])
231+
}, [
232+
tenantId,
233+
eventId,
234+
electionEventConfigUrl,
235+
location.pathname,
236+
loginHintsForCurrentRoute,
237+
setTenantEvent,
238+
])
194239

195240
useEffect(() => {
196241
if (isAuthenticated) {

packages/voting-portal/src/providers/AuthContextProvider.tsx

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {SettingsContext} from "./SettingsContextProvider"
1010
import {getLanguageFromURL} from "../utils/queryParams"
1111
import {useTranslation} from "react-i18next"
1212
import {IPermissions} from "../types/keycloak"
13+
import {appendLoginHints, LoginHints} from "../utils/loginHints"
1314

1415
/**
1516
* AuthContextValues defines the structure for the default values of the {@link AuthContext}.
@@ -66,7 +67,8 @@ export interface AuthContextValues {
6667
tenantId: string,
6768
eventId: string,
6869
authType?: "register" | "login",
69-
defaultLocale?: string
70+
defaultLocale?: string,
71+
loginHints?: LoginHints
7072
) => void
7173

7274
/**
@@ -100,7 +102,13 @@ const defaultAuthContextValues: AuthContextValues = {
100102
keycloakAccessToken: undefined,
101103
logout: () => {},
102104
getExpiry: () => undefined,
103-
setTenantEvent: (_tenantId: string, _eventId: string, _authType, _defaultLocale) => {},
105+
setTenantEvent: (
106+
_tenantId: string,
107+
_eventId: string,
108+
_authType,
109+
_defaultLocale,
110+
_loginHints
111+
) => {},
104112
hasRole: () => false,
105113
isKiosk: () => false,
106114
openProfileLink: () => new Promise(() => undefined),
@@ -144,6 +152,7 @@ const AuthContextProvider = (props: AuthContextProviderProps) => {
144152
const [eventId, setEventId] = useState<string | null>(null)
145153
const [authType, setAuthType] = useState<"register" | "login" | null>(null)
146154
const [defaultLocale, setDefaultLocale] = useState<string | undefined>(undefined)
155+
const [loginHints, setLoginHints] = useState<LoginHints>({})
147156

148157
const {i18n} = useTranslation()
149158

@@ -294,15 +303,41 @@ const AuthContextProvider = (props: AuthContextProviderProps) => {
294303
if (authType === "register") {
295304
const baseUrl = window.location.origin + window.location.pathname
296305
const queryString = window.location.search
297-
298-
return await keycloak.register({
306+
const registerOptions = {
299307
...keycloakInitOptions,
300308
// after successful enrollment, we should redirect to login
301309
redirectUri: baseUrl.endsWith("/enroll")
302310
? baseUrl.replace(/\/enroll$/, "/login") + queryString
303311
: undefined,
304-
})
312+
loginHint: loginHints.username,
313+
}
314+
315+
if (Object.keys(loginHints).length > 0) {
316+
window.location.assign(
317+
appendLoginHints(
318+
keycloak.createRegisterUrl(registerOptions),
319+
loginHints
320+
)
321+
)
322+
return
323+
}
324+
325+
return await keycloak.register(registerOptions)
305326
} else {
327+
if (Object.keys(loginHints).length > 0) {
328+
window.location.assign(
329+
appendLoginHints(
330+
keycloak.createLoginUrl({
331+
...keycloakInitOptions,
332+
// Stock username forms only understand the standard OIDC hint.
333+
loginHint: loginHints.username,
334+
}),
335+
loginHints
336+
)
337+
)
338+
return
339+
}
340+
306341
return await keycloak.login(keycloakInitOptions)
307342
}
308343
}
@@ -326,7 +361,7 @@ const AuthContextProvider = (props: AuthContextProviderProps) => {
326361
if (keycloak && !isAuthenticated && !isKeycloakInitialized) {
327362
initializeKeycloak()
328363
}
329-
}, [keycloak, isAuthenticated, isKeycloakInitialized, authType, defaultLocale])
364+
}, [keycloak, isAuthenticated, isKeycloakInitialized, authType, defaultLocale, loginHints])
330365

331366
/**
332367
* Returns true only if the JWT has gold permissions and the JWT
@@ -395,12 +430,14 @@ const AuthContextProvider = (props: AuthContextProviderProps) => {
395430
tenantId: string,
396431
eventId: string,
397432
authType?: "register" | "login",
398-
defaultLocale?: string
433+
defaultLocale?: string,
434+
initialLoginHints?: LoginHints
399435
) => {
400436
setTenantId(tenantId)
401437
setEventId(eventId)
402438
setDefaultLocale(defaultLocale)
403439
authType && setAuthType(authType)
440+
setLoginHints(initialLoginHints ?? {})
404441
}
405442

406443
const getRedirectUrl = (redirectUrl?: string) => {

packages/voting-portal/src/utils/loginHints.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe("parseLoginHints", () => {
7474
describe("appendLoginHints", () => {
7575
it("preserves OIDC parameters and percent-encodes hint names and values", () => {
7676
const result = appendLoginHints(
77-
"https://id.example/authorize?state=oidc-state&login_hint=user%40example.com",
77+
"https://id.example/authorize?state=oidc-state&nonce=oidc-nonce&code_challenge=pkce-challenge&redirect_uri=https%3A%2F%2Fvote.example%2Flogin&login_hint=user%40example.com",
7878
{
7979
username: "user@example.com",
8080
reference: "a&b=c % value",
@@ -83,6 +83,9 @@ describe("appendLoginHints", () => {
8383
const resultUrl = new URL(result)
8484

8585
expect(resultUrl.searchParams.get("state")).toBe("oidc-state")
86+
expect(resultUrl.searchParams.get("nonce")).toBe("oidc-nonce")
87+
expect(resultUrl.searchParams.get("code_challenge")).toBe("pkce-challenge")
88+
expect(resultUrl.searchParams.get("redirect_uri")).toBe("https://vote.example/login")
8689
expect(resultUrl.searchParams.get("login_hint")).toBe("user@example.com")
8790
expect(resultUrl.searchParams.get("login_hint__username")).toBe("user@example.com")
8891
expect(resultUrl.searchParams.get("login_hint__reference")).toBe("a&b=c % value")

0 commit comments

Comments
 (0)