@@ -21,11 +21,23 @@ import type OpenIDConnectStrategy from "@govtechsg/passport-openidconnect"
2121type 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 (
0 commit comments