From c30197180cd79c7b169623d62e3a35f7ab84c9f0 Mon Sep 17 00:00:00 2001 From: kawachi Date: Fri, 6 Mar 2026 16:08:53 +0900 Subject: [PATCH 1/3] fix: handle email_verified as JSON string or boolean in OidcUser Some OIDC providers such as Amazon Cognito return the email_verified claim as a JSON string ("true"/"false") instead of a JSON boolean, which violates the OIDC Core spec (section 5.1). Change VerifiedEmail field type from bool to json.RawMessage and use strings.Trim to normalize both representations before comparison. --- model/oauth.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/model/oauth.go b/model/oauth.go index 62943844..a3c7fe1d 100644 --- a/model/oauth.go +++ b/model/oauth.go @@ -1,6 +1,7 @@ package model import ( + "encoding/json" "errors" "strconv" "strings" @@ -112,7 +113,10 @@ type OauthUserBase struct { type OidcUser struct { OauthUserBase Sub string `json:"sub"` - VerifiedEmail bool `json:"email_verified"` + // VerifiedEmail is declared as json.RawMessage because some OIDC providers + // (e.g. Amazon Cognito) return email_verified as a JSON string ("true"/"false") + // instead of a JSON boolean, which violates the OIDC Core spec (section 5.1). + VerifiedEmail json.RawMessage `json:"email_verified"` PreferredUsername string `json:"preferred_username"` Picture string `json:"picture"` } @@ -126,12 +130,15 @@ func (ou *OidcUser) ToOauthUser() *OauthUser { username = strings.ToLower(ou.Email) } + // email_verified may be a JSON boolean or a JSON string depending on the provider. + verifiedEmail := strings.Trim(string(ou.VerifiedEmail), `"`) == "true" + return &OauthUser{ OpenId: ou.Sub, Name: ou.Name, Username: username, Email: ou.Email, - VerifiedEmail: ou.VerifiedEmail, + VerifiedEmail: verifiedEmail, Picture: ou.Picture, } } From 8d16473c9b2af891f52d1b6d0d382fec33c345aa Mon Sep 17 00:00:00 2001 From: kawachi Date: Fri, 6 Mar 2026 16:31:55 +0900 Subject: [PATCH 2/3] fix: use EqualFold for case-insensitive comparison and add unit tests for email_verified - Use strings.EqualFold to handle "True"/"TRUE" from non-standard providers - Document nil/absent field behavior (defaults to false) in comment - Add oauth_test.go covering bool, string, case-insensitive, null, and absent cases --- model/oauth.go | 3 ++- model/oauth_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 model/oauth_test.go diff --git a/model/oauth.go b/model/oauth.go index a3c7fe1d..0c700455 100644 --- a/model/oauth.go +++ b/model/oauth.go @@ -131,7 +131,8 @@ func (ou *OidcUser) ToOauthUser() *OauthUser { } // email_verified may be a JSON boolean or a JSON string depending on the provider. - verifiedEmail := strings.Trim(string(ou.VerifiedEmail), `"`) == "true" + // When the field is absent or null, ou.VerifiedEmail is nil and defaults to false. + verifiedEmail := strings.EqualFold(strings.Trim(string(ou.VerifiedEmail), `"`), "true") return &OauthUser{ OpenId: ou.Sub, diff --git a/model/oauth_test.go b/model/oauth_test.go new file mode 100644 index 00000000..23431f46 --- /dev/null +++ b/model/oauth_test.go @@ -0,0 +1,42 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestOidcUser_ToOauthUser_EmailVerified(t *testing.T) { + tests := []struct { + name string + emailVerified string // raw JSON value + want bool + }{ + {"boolean true", `true`, true}, + {"boolean false", `false`, false}, + {"string true", `"true"`, true}, + {"string false", `"false"`, false}, + {"string True (case-insensitive)", `"True"`, true}, + {"string TRUE (case-insensitive)", `"TRUE"`, true}, + {"null", `null`, false}, + {"absent (nil)", ``, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var raw json.RawMessage + if tt.emailVerified != "" { + raw = json.RawMessage(tt.emailVerified) + } + u := &OidcUser{ + OauthUserBase: OauthUserBase{Name: "Test", Email: "test@example.com"}, + Sub: "sub123", + VerifiedEmail: raw, + PreferredUsername: "testuser", + } + got := u.ToOauthUser() + if got.VerifiedEmail != tt.want { + t.Errorf("VerifiedEmail = %v, want %v (input: %s)", got.VerifiedEmail, tt.want, tt.emailVerified) + } + }) + } +} From e3865358ecb1feb9bf15acbb3a803ccd40fce4d4 Mon Sep 17 00:00:00 2001 From: kawachi Date: Fri, 6 Mar 2026 16:34:41 +0900 Subject: [PATCH 3/3] test: rewrite oauth_test.go to match project test style (per-case functions) --- model/oauth_test.go | 77 +++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/model/oauth_test.go b/model/oauth_test.go index 23431f46..bad0bfc9 100644 --- a/model/oauth_test.go +++ b/model/oauth_test.go @@ -5,38 +5,53 @@ import ( "testing" ) -func TestOidcUser_ToOauthUser_EmailVerified(t *testing.T) { - tests := []struct { - name string - emailVerified string // raw JSON value - want bool - }{ - {"boolean true", `true`, true}, - {"boolean false", `false`, false}, - {"string true", `"true"`, true}, - {"string false", `"false"`, false}, - {"string True (case-insensitive)", `"True"`, true}, - {"string TRUE (case-insensitive)", `"TRUE"`, true}, - {"null", `null`, false}, - {"absent (nil)", ``, false}, +func TestOidcUser_ToOauthUser_EmailVerifiedBoolTrue(t *testing.T) { + u := &OidcUser{VerifiedEmail: json.RawMessage(`true`)} + if !u.ToOauthUser().VerifiedEmail { + t.Fatal("expected true for JSON boolean true") } +} + +func TestOidcUser_ToOauthUser_EmailVerifiedBoolFalse(t *testing.T) { + u := &OidcUser{VerifiedEmail: json.RawMessage(`false`)} + if u.ToOauthUser().VerifiedEmail { + t.Fatal("expected false for JSON boolean false") + } +} + +func TestOidcUser_ToOauthUser_EmailVerifiedStringTrue(t *testing.T) { + u := &OidcUser{VerifiedEmail: json.RawMessage(`"true"`)} + if !u.ToOauthUser().VerifiedEmail { + t.Fatal("expected true for JSON string \"true\"") + } +} + +func TestOidcUser_ToOauthUser_EmailVerifiedStringFalse(t *testing.T) { + u := &OidcUser{VerifiedEmail: json.RawMessage(`"false"`)} + if u.ToOauthUser().VerifiedEmail { + t.Fatal("expected false for JSON string \"false\"") + } +} + +func TestOidcUser_ToOauthUser_EmailVerifiedStringCaseInsensitive(t *testing.T) { + for _, s := range []string{`"True"`, `"TRUE"`} { + u := &OidcUser{VerifiedEmail: json.RawMessage(s)} + if !u.ToOauthUser().VerifiedEmail { + t.Fatalf("expected true for %s", s) + } + } +} + +func TestOidcUser_ToOauthUser_EmailVerifiedNull(t *testing.T) { + u := &OidcUser{VerifiedEmail: json.RawMessage(`null`)} + if u.ToOauthUser().VerifiedEmail { + t.Fatal("expected false for JSON null") + } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var raw json.RawMessage - if tt.emailVerified != "" { - raw = json.RawMessage(tt.emailVerified) - } - u := &OidcUser{ - OauthUserBase: OauthUserBase{Name: "Test", Email: "test@example.com"}, - Sub: "sub123", - VerifiedEmail: raw, - PreferredUsername: "testuser", - } - got := u.ToOauthUser() - if got.VerifiedEmail != tt.want { - t.Errorf("VerifiedEmail = %v, want %v (input: %s)", got.VerifiedEmail, tt.want, tt.emailVerified) - } - }) +func TestOidcUser_ToOauthUser_EmailVerifiedAbsent(t *testing.T) { + u := &OidcUser{} + if u.ToOauthUser().VerifiedEmail { + t.Fatal("expected false when email_verified field is absent") } }