Skip to content

Commit e1afec6

Browse files
authored
fix: Optionally consume email_verified if it's provided (#3957)
This reduces our OIDC requirement claims to only `email`. If `email_verified` is provided and is `false`, we will block authentication. Fixes #3954.
1 parent bb4a681 commit e1afec6

File tree

2 files changed

+49
-20
lines changed

2 files changed

+49
-20
lines changed

coderd/userauth.go

+39-19
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,10 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
204204
return
205205
}
206206

207-
var claims struct {
208-
Email string `json:"email"`
209-
Verified bool `json:"email_verified"`
210-
Username string `json:"preferred_username"`
211-
Picture string `json:"picture"`
212-
}
207+
// "email_verified" is an optional claim that changes the behavior
208+
// of our OIDC handler, so each property must be pulled manually out
209+
// of the claim mapping.
210+
claims := map[string]interface{}{}
213211
err = idToken.Claims(&claims)
214212
if err != nil {
215213
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
@@ -218,47 +216,69 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
218216
})
219217
return
220218
}
221-
if claims.Email == "" {
219+
emailRaw, ok := claims["email"]
220+
if !ok {
222221
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
223222
Message: "No email found in OIDC payload!",
224223
})
225224
return
226225
}
227-
if !claims.Verified {
228-
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
229-
Message: fmt.Sprintf("Verify the %q email address on your OIDC provider to authenticate!", claims.Email),
226+
email, ok := emailRaw.(string)
227+
if !ok {
228+
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
229+
Message: fmt.Sprintf("Email in OIDC payload isn't a string. Got: %t", emailRaw),
230230
})
231231
return
232232
}
233+
verifiedRaw, ok := claims["email_verified"]
234+
if ok {
235+
verified, ok := verifiedRaw.(bool)
236+
if ok && !verified {
237+
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
238+
Message: fmt.Sprintf("Verify the %q email address on your OIDC provider to authenticate!", email),
239+
})
240+
return
241+
}
242+
}
243+
usernameRaw, ok := claims["preferred_username"]
244+
var username string
245+
if ok {
246+
username, _ = usernameRaw.(string)
247+
}
233248
// The username is a required property in Coder. We make a best-effort
234249
// attempt at using what the claims provide, but if that fails we will
235250
// generate a random username.
236-
if !httpapi.UsernameValid(claims.Username) {
251+
if !httpapi.UsernameValid(username) {
237252
// If no username is provided, we can default to use the email address.
238253
// This will be converted in the from function below, so it's safe
239254
// to keep the domain.
240-
if claims.Username == "" {
241-
claims.Username = claims.Email
255+
if username == "" {
256+
username = email
242257
}
243-
claims.Username = httpapi.UsernameFrom(claims.Username)
258+
username = httpapi.UsernameFrom(username)
244259
}
245260
if api.OIDCConfig.EmailDomain != "" {
246-
if !strings.HasSuffix(claims.Email, api.OIDCConfig.EmailDomain) {
261+
if !strings.HasSuffix(email, api.OIDCConfig.EmailDomain) {
247262
httpapi.Write(rw, http.StatusForbidden, codersdk.Response{
248-
Message: fmt.Sprintf("Your email %q is not a part of the %q domain!", claims.Email, api.OIDCConfig.EmailDomain),
263+
Message: fmt.Sprintf("Your email %q is not a part of the %q domain!", email, api.OIDCConfig.EmailDomain),
249264
})
250265
return
251266
}
252267
}
268+
var picture string
269+
pictureRaw, ok := claims["picture"]
270+
if ok {
271+
picture, _ = pictureRaw.(string)
272+
}
253273

254274
cookie, err := api.oauthLogin(r, oauthLoginParams{
255275
State: state,
256276
LinkedID: oidcLinkedID(idToken),
257277
LoginType: database.LoginTypeOIDC,
258278
AllowSignups: api.OIDCConfig.AllowSignups,
259-
Email: claims.Email,
260-
Username: claims.Username,
261-
AvatarURL: claims.Picture,
279+
Email: email,
280+
Username: username,
281+
AvatarURL: picture,
262282
})
263283
var httpErr httpError
264284
if xerrors.As(err, &httpErr) {

coderd/userauth_test.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,20 @@ func TestUserOIDC(t *testing.T) {
302302
AvatarURL string
303303
StatusCode int
304304
}{{
305-
Name: "EmailNotVerified",
305+
Name: "EmailOnly",
306306
Claims: jwt.MapClaims{
307307
"email": "kyle@kwc.io",
308308
},
309309
AllowSignups: true,
310+
StatusCode: http.StatusTemporaryRedirect,
311+
Username: "kyle",
312+
}, {
313+
Name: "EmailNotVerified",
314+
Claims: jwt.MapClaims{
315+
"email": "kyle@kwc.io",
316+
"email_verified": false,
317+
},
318+
AllowSignups: true,
310319
StatusCode: http.StatusForbidden,
311320
}, {
312321
Name: "NotInRequiredEmailDomain",

0 commit comments

Comments
 (0)