Skip to content

Commit 9ecd004

Browse files
committed
building verified user objects
1 parent fc86d33 commit 9ecd004

3 files changed

Lines changed: 57 additions & 7 deletions

File tree

packages/backend-core/src/middleware/passport/sso/google.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
2424
userId: profile.id,
2525
profile: profile,
2626
email: profile._json.email,
27+
emailVerified: true,
2728
oauth2: {
2829
accessToken,
2930
refreshToken,

packages/backend-core/src/middleware/passport/sso/oidc.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,23 @@ import type OpenIDConnectStrategy from "@govtechsg/passport-openidconnect"
2121
type OIDCUserInfoProfile = OpenIDConnectStrategy.MergedProfile & {
2222
_json?: {
2323
email?: string
24+
email_verified?: boolean
2425
}
2526
provider?: string
2627
}
2728

28-
export function buildVerifyFn(saveUserFn: SaveSSOUserFunction): VerifyFunction {
29+
// the id_token profile also exposes the raw claims via _json
30+
type OIDCIdProfile = OpenIDConnectStrategy.Profile & {
31+
_json?: {
32+
email?: string
33+
email_verified?: boolean
34+
}
35+
}
36+
37+
export function buildVerifyFn(
38+
saveUserFn: SaveSSOUserFunction,
39+
allowUnverifiedEmailLinking = false
40+
): VerifyFunction {
2941
/**
3042
* @param issuer The identity provider base URL
3143
* @param uiProfile The user profile information created by passport from the /userinfo response
@@ -57,6 +69,7 @@ export function buildVerifyFn(saveUserFn: SaveSSOUserFunction): VerifyFunction {
5769
userId: profile.id,
5870
profile: profile,
5971
email: getEmail(profile, jwtClaims),
72+
emailVerified: getEmailVerified(profile, jwtClaims),
6073
oauth2: {
6174
accessToken: accessToken,
6275
refreshToken: refreshToken,
@@ -67,7 +80,8 @@ export function buildVerifyFn(saveUserFn: SaveSSOUserFunction): VerifyFunction {
6780
details,
6881
false, // don't require local accounts to exist
6982
done,
70-
saveUserFn
83+
saveUserFn,
84+
allowUnverifiedEmailLinking
7185
)
7286
}
7387
}
@@ -80,6 +94,10 @@ function normalizeProfile(
8094

8195
if (!profileJson.email && idProfile.emails?.length) {
8296
profileJson.email = idProfile.emails[0].value
97+
// keep email_verified aligned with the email it describes
98+
profileJson.email_verified = (
99+
idProfile as OIDCIdProfile
100+
)._json?.email_verified
83101
}
84102

85103
const displayName = uiProfile?.displayName || idProfile.displayName
@@ -102,6 +120,7 @@ function buildJwtClaims(
102120
): JwtClaims {
103121
return {
104122
email: uiProfile?._json?.email || idProfile.emails?.[0]?.value,
123+
email_verified: (idProfile as OIDCIdProfile)._json?.email_verified,
105124
preferred_username: idProfile.username,
106125
}
107126
}
@@ -134,6 +153,27 @@ function getEmail(profile: SSOProfile, jwtClaims: JwtClaims) {
134153
)
135154
}
136155

156+
/**
157+
* Determines whether the identity provider has verified the email that
158+
* getEmail resolved. Mirrors getEmail's source precedence so the returned flag
159+
* describes the same claim. An absent email_verified is treated as unverified
160+
* (OIDC Core §5.7). A preferred_username used as an email is never considered
161+
* verified.
162+
* @param profile The structured profile created by passport using the user info endpoint
163+
* @param jwtClaims The claims returned in the id token
164+
*/
165+
function getEmailVerified(profile: SSOProfile, jwtClaims: JwtClaims): boolean {
166+
if (profile._json.email) {
167+
return profile._json.email_verified === true
168+
}
169+
170+
if (jwtClaims.email) {
171+
return jwtClaims.email_verified === true
172+
}
173+
174+
return false
175+
}
176+
137177
/**
138178
* Create an instance of the oidc passport strategy. This wrapper fetches the configuration
139179
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
@@ -144,7 +184,7 @@ export async function strategyFactory(
144184
saveUserFn: SaveSSOUserFunction
145185
) {
146186
try {
147-
const verify = buildVerifyFn(saveUserFn)
187+
const verify = buildVerifyFn(saveUserFn, config.allowUnverifiedEmailLinking)
148188
const strategy = new OIDCStrategy(config, verify)
149189
strategy.name = "oidc"
150190
return strategy
@@ -158,7 +198,13 @@ export async function fetchStrategyConfig(
158198
callbackUrl?: string
159199
): Promise<OIDCStrategyConfiguration> {
160200
try {
161-
const { clientID, clientSecret, configUrl, pkce } = oidcConfig
201+
const {
202+
clientID,
203+
clientSecret,
204+
configUrl,
205+
pkce,
206+
allowUnverifiedEmailLinking,
207+
} = oidcConfig
162208

163209
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
164210
// check for remote config and all required elements
@@ -186,6 +232,7 @@ export async function fetchStrategyConfig(
186232
clientSecret: clientSecret,
187233
callbackURL: callbackUrl,
188234
pkce: pkce,
235+
allowUnverifiedEmailLinking: allowUnverifiedEmailLinking,
189236
}
190237
} catch (err) {
191238
throw new Error(

packages/backend-core/src/middleware/passport/sso/sso.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export async function authenticate(
2222
details: SSOAuthDetails,
2323
requireLocalAccount = true,
2424
done: any,
25-
saveUserFn: SaveSSOUserFunction
25+
saveUserFn: SaveSSOUserFunction,
26+
allowUnverifiedEmailLinking = false
2627
) {
2728
if (!saveUserFn) {
2829
throw new Error("Save user function must be provided")
@@ -53,8 +54,9 @@ export async function authenticate(
5354
}
5455
}
5556

56-
// fallback to loading by email
57-
if (!dbUser) {
57+
// fallback to loading by email - only when the email has been verified by the
58+
// identity provider.
59+
if (!dbUser && (details.emailVerified || allowUnverifiedEmailLinking)) {
5860
dbUser = await users.getGlobalUserByEmail(details.email)
5961
}
6062

0 commit comments

Comments
 (0)