mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
+69
-20
@@ -3,6 +3,7 @@ package coderd
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -1348,29 +1349,41 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
verifiedRaw, ok := mergedClaims["email_verified"]
|
||||
if ok {
|
||||
verified, ok := verifiedRaw.(bool)
|
||||
if ok && !verified {
|
||||
if !api.OIDCConfig.IgnoreEmailVerified {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusForbidden,
|
||||
HideStatus: true,
|
||||
Title: "Email not verified",
|
||||
Description: fmt.Sprintf(
|
||||
"Verify the %q email address on your OIDC provider to authenticate!",
|
||||
email,
|
||||
),
|
||||
Actions: []site.Action{
|
||||
{URL: "/login", Text: "Back to login"},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Warn(ctx, "allowing unverified oidc email", slog.F("email", email))
|
||||
// Determine whether the email is verified. Default to unverified
|
||||
// so that a missing claim or an unrecognized type is fail-closed.
|
||||
emailVerified := false
|
||||
verifiedRaw, hasVerifiedClaim := mergedClaims["email_verified"]
|
||||
if hasVerifiedClaim {
|
||||
v, coerceOK := coerceEmailVerified(verifiedRaw)
|
||||
if coerceOK {
|
||||
emailVerified = v
|
||||
} else {
|
||||
logger.Warn(ctx, "unrecognized email_verified claim type, treating as unverified",
|
||||
slog.F("type", fmt.Sprintf("%T", verifiedRaw)),
|
||||
slog.F("value", verifiedRaw),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !emailVerified {
|
||||
if !api.OIDCConfig.IgnoreEmailVerified {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusForbidden,
|
||||
HideStatus: true,
|
||||
Title: "Email not verified",
|
||||
Description: fmt.Sprintf(
|
||||
"Verify the %q email address on your OIDC provider to authenticate!",
|
||||
email,
|
||||
),
|
||||
Actions: []site.Action{
|
||||
{URL: "/login", Text: "Back to login"},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Warn(ctx, "allowing unverified oidc email", slog.F("email", email))
|
||||
}
|
||||
|
||||
// The username is a required property in Coder. We make a best-effort
|
||||
// attempt at using what the claims provide, but if that fails we will
|
||||
// generate a random username.
|
||||
@@ -2235,3 +2248,39 @@ func wrongLoginTypeHTTPError(user database.LoginType, params database.LoginType)
|
||||
params, user, addedMsg),
|
||||
}
|
||||
}
|
||||
|
||||
// coerceEmailVerified attempts to convert an OIDC email_verified claim to a
|
||||
// boolean. Some IdPs (e.g. SAML-to-OIDC bridges, certain Azure AD B2C
|
||||
// configurations) return email_verified as a string ("true"/"false") or a
|
||||
// number (1/0) rather than a native JSON boolean. This function handles
|
||||
// those variants so that non-bool representations cannot silently bypass
|
||||
// the verification check.
|
||||
//
|
||||
// Returns (value, true) on successful coercion, or (false, false) if the
|
||||
// value is nil or an unrecognized type.
|
||||
func coerceEmailVerified(v interface{}) (verified bool, ok bool) {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val, true
|
||||
case string:
|
||||
b, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return b, true
|
||||
case json.Number:
|
||||
n, err := val.Int64()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return n != 0, true
|
||||
case float64:
|
||||
return val != 0, true
|
||||
case int64:
|
||||
return val != 0, true
|
||||
case int:
|
||||
return val != 0, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCoerceEmailVerified(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
wantBool bool
|
||||
wantOK bool
|
||||
}{
|
||||
// Native booleans
|
||||
{name: "BoolTrue", input: true, wantBool: true, wantOK: true},
|
||||
{name: "BoolFalse", input: false, wantBool: false, wantOK: true},
|
||||
|
||||
// Strings
|
||||
{name: "StringTrue", input: "true", wantBool: true, wantOK: true},
|
||||
{name: "StringFalse", input: "false", wantBool: false, wantOK: true},
|
||||
{name: "StringOne", input: "1", wantBool: true, wantOK: true},
|
||||
{name: "StringZero", input: "0", wantBool: false, wantOK: true},
|
||||
{name: "StringTRUE", input: "TRUE", wantBool: true, wantOK: true},
|
||||
{name: "StringFALSE", input: "FALSE", wantBool: false, wantOK: true},
|
||||
{name: "StringT", input: "t", wantBool: true, wantOK: true},
|
||||
{name: "StringF", input: "f", wantBool: false, wantOK: true},
|
||||
{name: "StringInvalid", input: "invalid", wantBool: false, wantOK: false},
|
||||
{name: "StringEmpty", input: "", wantBool: false, wantOK: false},
|
||||
|
||||
// json.Number (when decoder uses UseNumber)
|
||||
{name: "JSONNumberOne", input: json.Number("1"), wantBool: true, wantOK: true},
|
||||
{name: "JSONNumberZero", input: json.Number("0"), wantBool: false, wantOK: true},
|
||||
{name: "JSONNumberInvalid", input: json.Number("abc"), wantBool: false, wantOK: false},
|
||||
|
||||
// float64 (default JSON numeric type)
|
||||
{name: "Float64One", input: float64(1), wantBool: true, wantOK: true},
|
||||
{name: "Float64Zero", input: float64(0), wantBool: false, wantOK: true},
|
||||
|
||||
// Integer types
|
||||
{name: "IntOne", input: int(1), wantBool: true, wantOK: true},
|
||||
{name: "IntZero", input: int(0), wantBool: false, wantOK: true},
|
||||
{name: "Int64One", input: int64(1), wantBool: true, wantOK: true},
|
||||
{name: "Int64Zero", input: int64(0), wantBool: false, wantOK: true},
|
||||
|
||||
// Nil and unsupported types
|
||||
{name: "Nil", input: nil, wantBool: false, wantOK: false},
|
||||
{name: "Slice", input: []string{}, wantBool: false, wantOK: false},
|
||||
{name: "Map", input: map[string]string{}, wantBool: false, wantOK: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotBool, gotOK := coerceEmailVerified(tc.input)
|
||||
assert.Equal(t, tc.wantBool, gotBool, "bool value mismatch")
|
||||
assert.Equal(t, tc.wantOK, gotOK, "ok value mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
+103
-19
@@ -1128,7 +1128,8 @@ func TestUserOIDC(t *testing.T) {
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
AccessTokenClaims: jwt.MapClaims{
|
||||
"email": "kyle@kwc.io",
|
||||
"email": "kyle@kwc.io",
|
||||
"email_verified": true,
|
||||
},
|
||||
IgnoreUserInfo: true,
|
||||
AllowSignups: true,
|
||||
@@ -1151,8 +1152,9 @@ func TestUserOIDC(t *testing.T) {
|
||||
{
|
||||
Name: "EmailOnly",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
"email": "kyle@kwc.io",
|
||||
"sub": uuid.NewString(),
|
||||
"email": "kyle@kwc.io",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
AllowSignups: true,
|
||||
StatusCode: http.StatusOK,
|
||||
@@ -1160,6 +1162,29 @@ func TestUserOIDC(t *testing.T) {
|
||||
assert.Equal(t, "kyle", u.Username)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "EmailVerifiedAsStringTrue",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
"email": "kyle@kwc.io",
|
||||
"email_verified": "true",
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
AllowSignups: true,
|
||||
StatusCode: http.StatusOK,
|
||||
AssertUser: func(t testing.TB, u codersdk.User) {
|
||||
assert.Equal(t, "kyle", u.Username)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "EmailVerifiedAsStringFalse",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
"email": "kyle@kwc.io",
|
||||
"email_verified": "false",
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
AllowSignups: true,
|
||||
StatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
Name: "EmailNotVerified",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
@@ -1417,6 +1442,7 @@ func TestUserOIDC(t *testing.T) {
|
||||
// See: https://github.com/coder/coder/issues/4472
|
||||
Name: "UsernameIsEmail",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
"email_verified": true,
|
||||
"preferred_username": "kyle@kwc.io",
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
@@ -1466,9 +1492,10 @@ func TestUserOIDC(t *testing.T) {
|
||||
{
|
||||
Name: "GroupsDoesNothing",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
"email": "coolin@coder.com",
|
||||
"groups": []string{"pingpong"},
|
||||
"sub": uuid.NewString(),
|
||||
"email": "coolin@coder.com",
|
||||
"email_verified": true,
|
||||
"groups": []string{"pingpong"},
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
AllowSignups: true,
|
||||
StatusCode: http.StatusOK,
|
||||
@@ -1641,6 +1668,57 @@ func TestUserOIDC(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Absent email_verified claim tests use a FakeIDP that suppresses the
|
||||
// default email_verified=true injection so the handler's absent-claim
|
||||
// branch is exercised end-to-end.
|
||||
t.Run("EmailVerifiedMissing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := oidctest.NewFakeIDP(t,
|
||||
oidctest.WithRefresh(func(_ string) error {
|
||||
return xerrors.New("refreshing token should never occur")
|
||||
}),
|
||||
oidctest.WithServing(),
|
||||
oidctest.WithOmitEmailVerifiedDefault(),
|
||||
)
|
||||
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
|
||||
cfg.AllowSignups = true
|
||||
})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
OIDCConfig: cfg,
|
||||
})
|
||||
_, resp := fake.AttemptLogin(t, client, jwt.MapClaims{
|
||||
"email": "kyle@kwc.io",
|
||||
"sub": uuid.NewString(),
|
||||
})
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("EmailVerifiedMissingIgnored", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := oidctest.NewFakeIDP(t,
|
||||
oidctest.WithRefresh(func(_ string) error {
|
||||
return xerrors.New("refreshing token should never occur")
|
||||
}),
|
||||
oidctest.WithServing(),
|
||||
oidctest.WithOmitEmailVerifiedDefault(),
|
||||
)
|
||||
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
|
||||
cfg.AllowSignups = true
|
||||
cfg.IgnoreEmailVerified = true
|
||||
})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
OIDCConfig: cfg,
|
||||
})
|
||||
userClient, _ := fake.Login(t, client, jwt.MapClaims{
|
||||
"email": "kyle@kwc.io",
|
||||
"sub": uuid.NewString(),
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
user, err := userClient.User(ctx, "me")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "kyle", user.Username)
|
||||
})
|
||||
|
||||
t.Run("OIDCDormancy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -1670,8 +1748,9 @@ func TestUserOIDC(t *testing.T) {
|
||||
auditor.ResetLogs()
|
||||
|
||||
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
|
||||
"email": user.Email,
|
||||
"sub": uuid.NewString(),
|
||||
"email": user.Email,
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
})
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -1947,8 +2026,9 @@ func TestUserOIDC(t *testing.T) {
|
||||
require.Equal(t, codersdk.LoginTypePassword, userData.LoginType)
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"email": userData.Email,
|
||||
"sub": uuid.NewString(),
|
||||
"email": userData.Email,
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
var err error
|
||||
user.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
@@ -2018,8 +2098,9 @@ func TestUserOIDC(t *testing.T) {
|
||||
user, userData := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"email": userData.Email,
|
||||
"sub": uuid.NewString(),
|
||||
"email": userData.Email,
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
user.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
@@ -2089,8 +2170,9 @@ func TestUserOIDC(t *testing.T) {
|
||||
|
||||
numLogs := len(auditor.AuditLogs())
|
||||
claims := jwt.MapClaims{
|
||||
"email": "jon@coder.com",
|
||||
"sub": uuid.NewString(),
|
||||
"email": "jon@coder.com",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
|
||||
userClient, _ := fake.Login(t, client, claims)
|
||||
@@ -2104,8 +2186,9 @@ func TestUserOIDC(t *testing.T) {
|
||||
// Pass a different subject field so that we prompt creating a
|
||||
// new user
|
||||
userClient, _ = fake.Login(t, client, jwt.MapClaims{
|
||||
"email": "jon@example2.com",
|
||||
"sub": "diff",
|
||||
"email": "jon@example2.com",
|
||||
"email_verified": true,
|
||||
"sub": "diff",
|
||||
})
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
@@ -2470,9 +2553,10 @@ func TestOIDCSkipIssuer(t *testing.T) {
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
//nolint:bodyclose
|
||||
userClient, _ := fake.Login(t, owner, jwt.MapClaims{
|
||||
"iss": secondaryURLString,
|
||||
"email": "alice@coder.com",
|
||||
"sub": uuid.NewString(),
|
||||
"iss": secondaryURLString,
|
||||
"email": "alice@coder.com",
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
})
|
||||
found, err := userClient.User(ctx, "me")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -941,8 +941,9 @@ func TestPostUsers(t *testing.T) {
|
||||
|
||||
// Try to log in with OIDC.
|
||||
userClient, _ := fake.Login(t, client, jwt.MapClaims{
|
||||
"email": email,
|
||||
"sub": uuid.NewString(),
|
||||
"email": email,
|
||||
"email_verified": true,
|
||||
"sub": uuid.NewString(),
|
||||
})
|
||||
|
||||
found, err := userClient.User(ctx, "me")
|
||||
|
||||
Reference in New Issue
Block a user