diff --git a/dashboard/contributor.go b/dashboard/contributor.go index fad12a8..9919de5 100644 --- a/dashboard/contributor.go +++ b/dashboard/contributor.go @@ -488,8 +488,8 @@ func (c *Contributor) renderUserDetail(ctx context.Context, appID id.AppID, para actionError = "Failed to delete user: " + delErr.Error() } else { // Redirect to users list after deletion. - users, err := fetchUsers(ctx, c.engine, appID, "", 25) - if users == nil || err != nil { + users, fetchErr := fetchUsers(ctx, c.engine, appID, "", 25) + if users == nil || fetchErr != nil { users = &user.List{} } return pages.UsersPage(users, "", "../users"), nil diff --git a/engine.go b/engine.go index 01bebc2..63ddb4d 100644 --- a/engine.go +++ b/engine.go @@ -755,11 +755,11 @@ func (e *Engine) RebindLedgerOnPlugins() { if e.ledgerEng == nil || e.plugins == nil { return } - store := e.ledgerEng.Store() + ledgerStore := e.ledgerEng.Store() for _, p := range e.plugins.Plugins() { if rb, ok := p.(ledgerRebindable); ok { rb.SetLedger(e.ledgerEng) - rb.SetLedgerStore(store) + rb.SetLedgerStore(ledgerStore) } } } diff --git a/extension/extension.go b/extension/extension.go index c2c7496..328b7a4 100644 --- a/extension/extension.go +++ b/extension/extension.go @@ -28,7 +28,6 @@ import ( authsome "github.com/xraph/authsome" "github.com/xraph/authsome/api" - authcontract "github.com/xraph/authsome/extension/contract" "github.com/xraph/authsome/app" "github.com/xraph/authsome/appsessionconfig" "github.com/xraph/authsome/bridge" @@ -39,6 +38,7 @@ import ( "github.com/xraph/authsome/bridge/relayadapter" authdash "github.com/xraph/authsome/dashboard" "github.com/xraph/authsome/environment" + authcontract "github.com/xraph/authsome/extension/contract" "github.com/xraph/authsome/id" "github.com/xraph/authsome/lockout" "github.com/xraph/authsome/middleware" diff --git a/plugins/social/github.go b/plugins/social/github.go index 7f53986..c225f0e 100644 --- a/plugins/social/github.go +++ b/plugins/social/github.go @@ -64,6 +64,17 @@ func (p *githubProvider) FetchUser(ctx context.Context, token *oauth2.Token) (*P return nil, fmt.Errorf("social: github: decode user: %w", err) } + // GitHub's /user endpoint only returns the user's *public* email. Accounts + // that keep their email private (the default) return an empty string here. + // Fall back to /user/emails to fetch the primary verified email. This + // requires the "user:email" scope (already requested by default above). + email := info.Email + if email == "" { + if primary, emailErr := p.fetchPrimaryEmail(ctx, client); emailErr == nil { + email = primary + } + } + name := info.Name if name == "" { name = info.Login @@ -71,8 +82,51 @@ func (p *githubProvider) FetchUser(ctx context.Context, token *oauth2.Token) (*P return &ProviderUser{ ProviderUserID: fmt.Sprintf("%d", info.ID), - Email: info.Email, + Email: email, FirstName: name, AvatarURL: info.AvatarURL, }, nil } + +// fetchPrimaryEmail calls GitHub's /user/emails endpoint and returns the +// user's primary verified email. Requires the "user:email" OAuth scope. +// Returns an empty string (no error) if no verified primary is available. +func (p *githubProvider) fetchPrimaryEmail(ctx context.Context, client *http.Client) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/emails", http.NoBody) + if err != nil { + return "", fmt.Errorf("social: github: create emails request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("social: github: fetch emails: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck // best-effort read + return "", fmt.Errorf("social: github: fetch emails: status %d: %s", resp.StatusCode, body) + } + + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil { + return "", fmt.Errorf("social: github: decode emails: %w", err) + } + + // Prefer primary + verified. + for _, e := range emails { + if e.Primary && e.Verified { + return e.Email, nil + } + } + // Fall back to any verified email. + for _, e := range emails { + if e.Verified { + return e.Email, nil + } + } + return "", nil +} diff --git a/service.go b/service.go index 5290788..8a86498 100644 --- a/service.go +++ b/service.go @@ -291,7 +291,7 @@ func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (*user. if req.AppID.Prefix() != "" { resolveOpts.AppID = req.AppID.String() } - if dynVal, err := settings.Get(ctx, mgr, SettingRequireEmailVerification, resolveOpts); err == nil && dynVal { + if dynVal, dynErr := settings.Get(ctx, mgr, SettingRequireEmailVerification, resolveOpts); dynErr == nil && dynVal { requireVerif = true } } diff --git a/switchorg_test.go b/switchorg_test.go index 744c130..ff6950d 100644 --- a/switchorg_test.go +++ b/switchorg_test.go @@ -44,7 +44,7 @@ func TestSwitchActiveOrg_clearsActiveOrg(t *testing.T) { eng, store := newTestEngine(t, authsome.WithPlugin(&fakeOrgPlugin{})) ctx := context.Background() - sess := newSeedSession(t, store, ctx, id.NewOrgID()) + sess := newSeedSession(ctx, t, store, id.NewOrgID()) // Empty newOrgID is allowed and clears the active org. updated, err := eng.SwitchActiveOrg(ctx, sess.ID, id.OrgID{}) @@ -93,7 +93,7 @@ func TestSwitchActiveOrg_pluginMissing_returnsError(t *testing.T) { // must error rather than panic on the nil plugin. eng, store := newTestEngine(t) ctx := context.Background() - sess := newSeedSession(t, store, ctx, id.OrgID{}) + sess := newSeedSession(ctx, t, store, id.OrgID{}) _, err := eng.SwitchActiveOrg(ctx, sess.ID, id.NewOrgID()) require.Error(t, err) @@ -103,9 +103,9 @@ func TestSwitchActiveOrg_pluginMissing_returnsError(t *testing.T) { // newSeedSession persists a fresh session for a synthetic user and // returns it. The session has no OrgID by default unless the caller // passes one. -func newSeedSession(t *testing.T, store interface { +func newSeedSession(ctx context.Context, t *testing.T, store interface { CreateSession(ctx context.Context, s *session.Session) error -}, ctx context.Context, orgID id.OrgID) *session.Session { +}, orgID id.OrgID) *session.Session { t.Helper() sess := &session.Session{ ID: id.NewSessionID(),