fix!: reject OIDC login when email_verified claim is non-bool or absent (#25713)

## Problem

The OIDC callback checks `email_verified` via a Go type assertion
(`verifiedRaw.(bool)`). When an IdP returns the claim as a string
(`"false"`), a number, or omits it entirely, the assertion fails
silently and the email is implicitly treated as verified. Several real
IdPs (SAML-to-OIDC bridges, certain Azure AD B2C configurations) emit
string-typed booleans, making this reachable in practice.

## Fix

Add `coerceEmailVerified()` to handle `bool`, `string`
(`"true"`/`"false"`/`"1"`/`"0"` via `strconv.ParseBool`), `float64`,
`json.Number`, and `int`/`int64` variants. Rewrite the check to be
fail-closed: an absent claim, an unrecognized type, or any non-truthy
value is treated as unverified and rejected. The existing
`IgnoreEmailVerified` config option remains as an escape hatch.

Fixes https://linear.app/codercom/issue/PLAT-228

> Generated with [Coder Agents](https://coder.com) by @f0ssel

<details><summary>Implementation plan</summary>

### Production code (`coderd/userauth.go`)
- Added `encoding/json` import
- Added `coerceEmailVerified(v interface{}) (verified bool, ok bool)`
helper near EOF
- Replaced the type-assertion block (lines ~1342-1363) with fail-closed
logic that uses `coerceEmailVerified`

### Unit tests (`coderd/userauth_internal_test.go`, new file)
- Table-driven test covering: `bool`, `string` (`"true"`, `"false"`,
`"1"`, `"0"`, `"TRUE"`, `"t"`, `"f"`, `"invalid"`, `""`), `json.Number`,
`float64`, `int`, `int64`, `nil`, `[]string{}`, `map[string]string{}`

### Integration tests (`coderd/userauth_test.go`,
`coderd/users_test.go`)
- Added 3 new test cases: `EmailVerifiedMissingIgnored` (200),
`EmailVerifiedAsStringTrue` (200), `EmailVerifiedAsStringFalse` (403)
- Updated existing test cases that omitted `email_verified` and expected
success to include `"email_verified": true`

### FakeIDP (`coderd/coderdtest/oidctest/idp.go`)
- `encodeClaims` now defaults `email_verified` to `true` (like `exp`,
`aud`, `iss`) so tests that don't care about the verification flow are
unaffected
</details>
This commit is contained in:
Garrett Delfosse
2026-06-04 14:37:19 -04:00
committed by GitHub
parent 53d287a139
commit d5b0e93c6c
6 changed files with 264 additions and 44 deletions
+23 -2
View File
@@ -216,8 +216,9 @@ type FakeIDP struct {
hookAuthenticateClient func(t testing.TB, req *http.Request) (url.Values, error)
serve bool
// optional middlewares
middlewares chi.Middlewares
defaultExpire time.Duration
middlewares chi.Middlewares
defaultExpire time.Duration
omitEmailVerifiedDefault bool
}
func StatusError(code int, err error) error {
@@ -378,6 +379,15 @@ func WithIssuer(issuer string) func(*FakeIDP) {
}
}
// WithOmitEmailVerifiedDefault suppresses the default email_verified=true
// injection in encodeClaims. Use this for tests that exercise the handler's
// absent-claim rejection path.
func WithOmitEmailVerifiedDefault() func(*FakeIDP) {
return func(f *FakeIDP) {
f.omitEmailVerifiedDefault = true
}
}
type With429Arguments struct {
AllPaths bool
TokenPath bool
@@ -907,6 +917,17 @@ func (f *FakeIDP) encodeClaims(t testing.TB, claims jwt.MapClaims) string {
claims["iss"] = f.locked.Issuer()
}
// Default email_verified to true so that tests that do not care
// about the email_verified flow are not forced to set it.
// Tests that need a different value can set it explicitly.
// Use WithOmitEmailVerifiedDefault() to suppress this default
// for tests that need to exercise the absent-claim path.
if !f.omitEmailVerifiedDefault {
if _, ok := claims["email_verified"]; !ok {
claims["email_verified"] = true
}
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(f.locked.PrivateKey())
require.NoError(t, err)