From 7517d56246903c96ec357f6013eaf7a30a643e00 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Tue, 2 Jun 2026 23:21:08 +0530 Subject: [PATCH 1/4] fix(auth): OAuth must require a provider-verified email (#7/#9, account takeover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub and Google OAuth linked accounts by email WITHOUT confirming the provider had verified that email — an attacker controlling an unverified address equal to a victim's could link into / impersonate the victim. - GitHub (#9): fetchGitHubUser now ALWAYS resolves the address from /user/emails and accepts ONLY a primary+verified entry, ignoring the attacker-settable public /user profile email entirely. findOrCreateUserGitHub refuses to link-by-email or create a new identity when no verified email resolved. - Google (#7): decode email_verified (tokeninfo, string) / verified_email (userinfo v2, bool) onto googleUser.EmailVerified; findOrCreateUserGoogle refuses link-by-email / create unless verified. Existing google_id matches are unaffected. Per product decision 2026-06-02 (require verified email, reject if unverified). Hermetic regression tests added (httptest, no DB): verified-primary wins over public email; unverified → empty/ rejected; Google verified flag flows through. Test harness updated to emit the verified flags. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/auth.go | 110 ++++++++++++------ internal/handlers/auth_oauth_coverage_test.go | 28 ++++- .../auth_oauth_helpers_whitebox_test.go | 64 ++++++++++ 3 files changed, 161 insertions(+), 41 deletions(-) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 30fd8a41..c8f98020 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -710,28 +710,32 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string return nil, fmt.Errorf("github profile decode: %w", err) } - if profile.Email == "" { - // Fetch primary email separately - emailReq, _ := http.NewRequestWithContext(ctx, "GET", githubUserEmailURL, nil) - emailReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) - emailResp, err := client.Do(emailReq) - if err == nil { - defer func() { _ = emailResp.Body.Close() }() - body, _ := io.ReadAll(emailResp.Body) - var emails []struct { - Email string `json:"email"` - Primary bool `json:"primary"` - Verified bool `json:"verified"` - } - if json.Unmarshal(body, &emails) == nil { - for _, e := range emails { - // Only accept the primary AND verified address — - // an unverified email is attacker-controllable and - // must never seed a platform identity. - if e.Primary && e.Verified { - profile.Email = e.Email - break - } + // SECURITY (bug bash #9): GitHub's /user endpoint returns the account's + // PUBLIC profile email, which can be UNVERIFIED and is attacker-settable — + // trusting it lets an attacker link into a victim's account by email. We + // therefore IGNORE profile.Email entirely and ALWAYS resolve the address + // from /user/emails, accepting ONLY a primary+verified entry. If none + // exists, Email stays "" and findOrCreateUserGitHub refuses to link/create. + profile.Email = "" + emailReq, _ := http.NewRequestWithContext(ctx, "GET", githubUserEmailURL, nil) + emailReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) + emailResp, err := client.Do(emailReq) + if err == nil { + defer func() { _ = emailResp.Body.Close() }() + body, _ := io.ReadAll(emailResp.Body) + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + if json.Unmarshal(body, &emails) == nil { + for _, e := range emails { + // Only accept the primary AND verified address — an unverified + // email is attacker-controllable and must never seed/link a + // platform identity. + if e.Primary && e.Verified { + profile.Email = e.Email + break } } } @@ -761,6 +765,14 @@ func (h *AuthHandler) findOrCreateUserGitHub(ctx context.Context, gh *gitHubUser return nil, nil, fmt.Errorf("findOrCreateUserGitHub lookup: %w", err) } + // SECURITY (bug bash #9): an EXISTING github_id match (handled above) may + // proceed regardless, but link-by-email / new-identity creation MUST have a + // verified primary email (fetchGitHubUser only sets gh.Email from a + // primary+verified /user/emails entry). Refuse otherwise. + if gh.Email == "" { + return nil, nil, errOAuthEmailUnverified + } + // No GitHub-ID match. Before creating a brand-new team/user — which // fragments the identity of someone who already signed up via magic-link // or Google — try to match an existing account by email and attach the @@ -832,8 +844,21 @@ type googleUser struct { Sub string Email string Name string + // EmailVerified is Google's assertion that it controls/verified the + // address. Populated from the ID-token's `email_verified` (a STRING + // "true"/"false" on the tokeninfo endpoint) or the userinfo v2 + // `verified_email` (bool). We refuse to link-by-email or seed a new + // identity on an unverified email (bug bash #7). + EmailVerified bool } +// errOAuthEmailUnverified is returned by findOrCreateUserGitHub / +// findOrCreateUserGoogle when an OAuth provider could not assert a verified +// primary email and the request would otherwise create or link an identity +// by that email. Closes the account-takeover vector (bug bash #7/#9). The +// OAuth callbacks map it to a 4xx login failure. +var errOAuthEmailUnverified = errors.New("oauth provider did not supply a verified email") + func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*googleUser, error) { verifyURL := fmt.Sprintf("%s?id_token=%s", googleTokenInfoURL, url.QueryEscape(idToken)) req, _ := http.NewRequestWithContext(ctx, "GET", verifyURL, nil) @@ -850,11 +875,12 @@ func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*google } var payload struct { - Sub string `json:"sub"` - Email string `json:"email"` - Name string `json:"name"` - Aud string `json:"aud"` - Error string `json:"error_description"` + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + Aud string `json:"aud"` + Error string `json:"error_description"` + EmailVerified string `json:"email_verified"` // tokeninfo returns "true"/"false" as a string } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, fmt.Errorf("google payload decode: %w", err) @@ -867,9 +893,10 @@ func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*google } return &googleUser{ - Sub: payload.Sub, - Email: payload.Email, - Name: payload.Name, + Sub: payload.Sub, + Email: payload.Email, + Name: payload.Name, + EmailVerified: payload.EmailVerified == "true", }, nil } @@ -931,9 +958,10 @@ func fetchGoogleUserInfoOAuth2V2(ctx context.Context, accessToken string) (*goog } var payload struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + VerifiedEmail bool `json:"verified_email"` // userinfo v2 returns a bool } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, fmt.Errorf("google userinfo decode: %w", err) @@ -946,9 +974,10 @@ func fetchGoogleUserInfoOAuth2V2(ctx context.Context, accessToken string) (*goog } return &googleUser{ - Sub: payload.ID, - Email: payload.Email, - Name: payload.Name, + Sub: payload.ID, + Email: payload.Email, + Name: payload.Name, + EmailVerified: payload.VerifiedEmail, }, nil } @@ -1383,6 +1412,15 @@ func (h *AuthHandler) findOrCreateUserGoogle(ctx context.Context, g *googleUser) return nil, nil, fmt.Errorf("findOrCreateUserGoogle lookup: %w", err) } + // SECURITY (bug bash #7): only an EXISTING google_id match (handled above) + // may proceed on an unverified email. For link-by-email or new-identity + // creation we MUST require a Google-verified email — otherwise an attacker + // who controls an unverified Google account whose email equals a victim's + // could link into / impersonate the victim's account. + if !g.EmailVerified { + return nil, nil, errOAuthEmailUnverified + } + // Match existing account by email and link google_id when unset. if g.Email != "" { byEmail, errEmail := models.GetUserByEmail(ctx, h.db, strings.ToLower(strings.TrimSpace(g.Email))) diff --git a/internal/handlers/auth_oauth_coverage_test.go b/internal/handlers/auth_oauth_coverage_test.go index b04fada3..392eb547 100644 --- a/internal/handlers/auth_oauth_coverage_test.go +++ b/internal/handlers/auth_oauth_coverage_test.go @@ -46,15 +46,20 @@ import ( // successive requests can return different identities (existing-user + link). type fakeOAuthServer struct { ghID string - ghEmail string // email returned by /gh/user (empty → forces /gh/emails fetch) - ghPrimaryEmail string // email returned by /gh/emails as primary+verified + ghEmail string // email returned by /gh/user (public profile email — no longer trusted) + ghPrimaryEmail string // email returned by /gh/emails as primary+verified (defaults to ghEmail) + ghUnverified bool // when true, /gh/emails returns verified:false (simulate unverified) ghTokenErr bool gAud string gSub string // google subject id (default g-sub-123) gEmail string // google email (default g@example.com) + gUnverified bool // when true, Google userinfo/tokeninfo report email NOT verified gTokenNoAccess bool } +// gVerified renders the Google verified flag (default true) for the harness. +func (f *fakeOAuthServer) gVerified() bool { return !f.gUnverified } + func (f *fakeOAuthServer) gSubOr() string { if f.gSub != "" { return f.gSub @@ -89,11 +94,24 @@ func (f *fakeOAuthServer) handler() http.Handler { }) mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `[{"email":%q,"primary":true,"verified":true}]`, f.ghPrimaryEmail) + // fetchGitHubUser now ALWAYS resolves the email from /user/emails + // (the public /user email is no longer trusted — bug bash #9). For a + // realistic verified-primary, fall back to ghEmail when ghPrimaryEmail + // isn't explicitly set. Tests that want to simulate an unverified / + // missing primary set ghUnverified=true. + primary := f.ghPrimaryEmail + if primary == "" { + primary = f.ghEmail + } + verified := "true" + if f.ghUnverified { + verified = "false" + } + _, _ = fmt.Fprintf(w, `[{"email":%q,"primary":true,"verified":%s}]`, primary, verified) }) mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"G User","aud":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud) + _, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"G User","aud":%q,"email_verified":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud, map[bool]string{true: "true", false: "false"}[f.gVerified()]) }) mux.HandleFunc("/g/token", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -105,7 +123,7 @@ func (f *fakeOAuthServer) handler() http.Handler { }) mux.HandleFunc("/g/userinfo", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"id":%q,"email":%q,"name":"G User"}`, f.gSubOr(), f.gEmailOr()) + _, _ = fmt.Fprintf(w, `{"id":%q,"email":%q,"name":"G User","verified_email":%t}`, f.gSubOr(), f.gEmailOr(), f.gVerified()) }) return mux } diff --git a/internal/handlers/auth_oauth_helpers_whitebox_test.go b/internal/handlers/auth_oauth_helpers_whitebox_test.go index cecd6c68..dc07ce68 100644 --- a/internal/handlers/auth_oauth_helpers_whitebox_test.go +++ b/internal/handlers/auth_oauth_helpers_whitebox_test.go @@ -229,3 +229,67 @@ func TestAuth_generateOAuthState_And_generateSessionID(t *testing.T) { require.NoError(t, err) assert.Len(t, s2, 32) } + +// --- bug bash #9: GitHub email must come from a primary+verified entry --- + +func TestAuth_exchangeGitHubCode_IgnoresUnverifiedPublicEmail(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/gh/token", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"access_token":"x"}`)) + }) + mux.HandleFunc("/gh/user", func(w http.ResponseWriter, r *http.Request) { + // Attacker-controllable PUBLIC profile email — must be ignored. + _, _ = w.Write([]byte(`{"id":7,"login":"octocat","email":"attacker-public@evil.test"}`)) + }) + mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"email":"real-verified@example.com","primary":true,"verified":true}]`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + setHelperURLs(t, srv.URL) + + gh, err := exchangeGitHubCode(context.Background(), "id", "secret", "code") + require.NoError(t, err) + require.Equal(t, "real-verified@example.com", gh.Email, + "must resolve the verified primary, NOT the public /user email") +} + +func TestAuth_exchangeGitHubCode_NoVerifiedPrimary_EmptyEmail(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/gh/token", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"access_token":"x"}`)) + }) + mux.HandleFunc("/gh/user", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":8,"login":"octocat","email":"public@evil.test"}`)) + }) + mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) { + // Primary but NOT verified → must be ignored, leaving Email empty. + _, _ = w.Write([]byte(`[{"email":"public@evil.test","primary":true,"verified":false}]`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + setHelperURLs(t, srv.URL) + + gh, err := exchangeGitHubCode(context.Background(), "id", "secret", "code") + require.NoError(t, err) + require.Equal(t, "", gh.Email, "no primary+verified email → Email empty (login then refused)") +} + +// --- bug bash #7: Google verified_email flows onto googleUser.EmailVerified --- + +func TestAuth_fetchGoogleUserInfo_VerifiedEmailFlag(t *testing.T) { + serve := func(body string) *googleUser { + mux := http.NewServeMux() + mux.HandleFunc("/g/userinfo", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + setHelperURLs(t, srv.URL) + g, err := fetchGoogleUserInfoOAuth2V2(context.Background(), "tok") + require.NoError(t, err) + return g + } + require.True(t, serve(`{"id":"g1","email":"u@example.com","verified_email":true}`).EmailVerified) + require.False(t, serve(`{"id":"g1","email":"u@example.com","verified_email":false}`).EmailVerified) +} From f84a575d22cc3b4a40293bba1263e613b8036fbc Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Tue, 2 Jun 2026 23:42:57 +0530 Subject: [PATCH 2/4] test(auth): cover OAuth unverified-email refusal (#7/#9 reject branches) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB-backed tests: GitHub login with no primary+verified email and Google login with email_verified=false are both REFUSED (non-200) — exercises the errOAuthEmailUnverified return branches. Closes the #219 patch-coverage gap on auth.go. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/auth_oauth_coverage_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/handlers/auth_oauth_coverage_test.go b/internal/handlers/auth_oauth_coverage_test.go index 392eb547..2e1414f5 100644 --- a/internal/handlers/auth_oauth_coverage_test.go +++ b/internal/handlers/auth_oauth_coverage_test.go @@ -257,6 +257,41 @@ func TestAuth_GitHub_HappyPath(t *testing.T) { assert.NotEmpty(t, body["token"]) } +// bug bash #9: GitHub OAuth with NO primary+verified email must be REFUSED +// (no link/create on an unverified address). ghUnverified makes /gh/emails +// report verified:false → fetchGitHubUser resolves an empty email → +// findOrCreateUserGitHub returns errOAuthEmailUnverified. +func TestAuth_GitHub_UnverifiedEmail_Refused(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + settleAuditDB(t, db) + startFakeOAuth(t, &fakeOAuthServer{ghID: uniqueGHID(), ghEmail: testhelpers.UniqueEmail(t), ghUnverified: true}) + + app := buildAuthApp(handlers.NewAuthHandler(db, oauthCfg())) + resp := oauthPostJSON(t, app, "/auth/github", `{"code":"abc"}`) + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + t.Fatalf("GitHub login with no verified email must NOT succeed; got 200") + } +} + +// bug bash #7: Google OAuth with email NOT verified must be REFUSED for a new +// identity. gUnverified makes userinfo/tokeninfo report the email unverified → +// findOrCreateUserGoogle returns errOAuthEmailUnverified. +func TestAuth_Google_UnverifiedEmail_Refused(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + settleAuditDB(t, db) + startFakeOAuth(t, &fakeOAuthServer{gSub: "g-unverified-" + uniqueGHID(), gEmail: testhelpers.UniqueEmail(t), gUnverified: true}) + + app := buildAuthApp(handlers.NewAuthHandler(db, oauthCfg())) + resp := oauthPostJSON(t, app, "/auth/google", `{"id_token":"abc"}`) + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + t.Fatalf("Google login with unverified email must NOT succeed; got 200") + } +} + func TestAuth_GitHub_MissingCodeAndBadBody(t *testing.T) { app := buildAuthApp(handlers.NewAuthHandler(nil, oauthCfg())) From 9f9fdfe142054e96e045c79953350046f6a6ff41 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 3 Jun 2026 00:04:26 +0530 Subject: [PATCH 3/4] test(api): fix OAuth + tier tests broken by batch-2 on this branch - auth_final2 startEmptyNameGoogleOAuth: emit email_verified:"true" so the Google verified-email requirement (#7) is satisfied by the bespoke server. - tier_enforcement IsDedicatedTier: team is now dedicated (#12, via merged common defaultYAML). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/handlers/auth_final2_test.go | 4 +++- internal/handlers/tier_enforcement_test.go | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/handlers/auth_final2_test.go b/internal/handlers/auth_final2_test.go index 106fa804..ce38d7c2 100644 --- a/internal/handlers/auth_final2_test.go +++ b/internal/handlers/auth_final2_test.go @@ -146,7 +146,9 @@ func startEmptyNameGoogleOAuth(t *testing.T, sub, email string) { mux := http.NewServeMux() mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"","aud":"g-client"}`, sub, email) + // email_verified:"true" — Google OAuth now requires a verified email + // (bug bash #7); this bespoke server must emit it like the real one. + _, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"","aud":"g-client","email_verified":"true"}`, sub, email) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) diff --git a/internal/handlers/tier_enforcement_test.go b/internal/handlers/tier_enforcement_test.go index c7bf806f..35d539c3 100644 --- a/internal/handlers/tier_enforcement_test.go +++ b/internal/handlers/tier_enforcement_test.go @@ -536,8 +536,8 @@ func TestPlansRegistry_IsDedicatedTier(t *testing.T) { {"hobby_yearly", false}, {"pro", false}, {"pro_yearly", false}, - {"growth", true}, // the only dedicated tier in plans.yaml - {"team", false}, // team is unlimited but not dedicated + {"growth", true}, // dedicated infra + {"team", true}, // bug bash #12: Team ($199, above Growth) is dedicated too } for _, c := range cases { From 781072a27b0b0c22d61dc66a8957382edd47dd01 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 3 Jun 2026 01:22:32 +0530 Subject: [PATCH 4/4] test(auth): cover the unverified-email guard in findOrCreateUserGoogle (#219 coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff-cover flagged auth.go:1420-1422 (the bug-bash #7 account-takeover guard) uncovered: the existing handler-level test drives the id_token/body flow, but the browser-callback path (GoogleCallbackBrowser → userinfo v2 → findOrCreateUserGoogle) reaches the guard via a code path that test doesn't exercise deterministically. Adds hermetic white-box tests (package handlers, sqlmock — no DB container) that call findOrCreateUserGoogle / findOrCreateUserGitHub directly with an unverified/empty-email new identity and a missing google_id/github_id lookup, asserting errOAuthEmailUnverified. Locks the security guard against regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../auth_oauth_unverified_whitebox_test.go | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 internal/handlers/auth_oauth_unverified_whitebox_test.go diff --git a/internal/handlers/auth_oauth_unverified_whitebox_test.go b/internal/handlers/auth_oauth_unverified_whitebox_test.go new file mode 100644 index 00000000..ac1ab2f2 --- /dev/null +++ b/internal/handlers/auth_oauth_unverified_whitebox_test.go @@ -0,0 +1,69 @@ +package handlers + +// auth_oauth_unverified_whitebox_test.go — deterministic coverage for the +// bug-bash #7/#9 account-takeover guard: findOrCreateUserGoogle / +// findOrCreateUserGitHub MUST refuse to link-by-email or seed a new identity +// when the provider did not assert a verified email. +// +// The handler-level tests (auth_oauth_coverage_test.go) drive the body/id_token +// flow, but the BROWSER-callback path (GoogleCallbackBrowser → userinfo v2 → +// findOrCreateUserGoogle) reaches the guard via a code path those tests don't +// exercise, leaving auth.go:1420-1422 uncovered. These white-box tests call the +// upsert helpers directly with EmailVerified=false and a sqlmock'd +// google_id/github_id lookup that misses (→ ErrUserNotFound), so the guard is +// the very next branch — hermetic, no DB container required. + +import ( + "context" + "database/sql" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" +) + +func TestFindOrCreateUserGoogle_UnverifiedEmail_Refused_Whitebox(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + // GetUserByGoogleID misses → *ErrUserNotFound, so the function falls + // through to the verified-email guard rather than returning an existing + // google_id match. + mock.ExpectQuery(`FROM users WHERE google_id`). + WithArgs("new-sub"). + WillReturnError(sql.ErrNoRows) + + h := NewAuthHandler(db, &config.Config{}) + _, _, err = h.findOrCreateUserGoogle(context.Background(), &googleUser{ + Sub: "new-sub", + Email: "victim@example.com", + EmailVerified: false, + }) + require.ErrorIs(t, err, errOAuthEmailUnverified, + "a new Google identity on an UNVERIFIED email must be refused (account-takeover guard)") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestFindOrCreateUserGitHub_UnverifiedEmail_Refused_Whitebox(t *testing.T) { + // Symmetric guard for GitHub (auth.go:773): fetchGitHubUser only sets + // gh.Email from a primary+verified /user/emails entry, so an empty Email + // means "no verified primary email" — link-by-email / new-identity must be + // refused. GetUserByGitHubID runs first; mock it to miss so we fall through. + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + mock.ExpectQuery(`FROM users WHERE github_id`). + WithArgs("gh-new"). + WillReturnError(sql.ErrNoRows) + + h := NewAuthHandler(db, &config.Config{}) + _, _, gErr := h.findOrCreateUserGitHub(context.Background(), &gitHubUser{ + ID: "gh-new", + Email: "", // no verified primary email + }) + require.ErrorIs(t, gErr, errOAuthEmailUnverified, + "a new GitHub identity with no verified primary email must be refused") + require.NoError(t, mock.ExpectationsWereMet()) +}