diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 42010fad..8b89a5bf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,8 @@ "Bash(/Users/rexraphael/Work/xraph/forgery/authsome/store/bun/store.go:*)", "Bash(/Users/rexraphael/Work/xraph/forgery/authsome/middleware/rbac.go:*)", "Bash(/Users/rexraphael/Work/xraph/forgery/authsome/rbac/store_memory_test.go:*)", - "Bash(done)" + "Bash(done)", + "Bash(make f *)" ] } } diff --git a/dashboard/contributor.go b/dashboard/contributor.go index 876d3b09..b03722b6 100644 --- a/dashboard/contributor.go +++ b/dashboard/contributor.go @@ -404,8 +404,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 7b3a46e2..33d4999f 100644 --- a/engine.go +++ b/engine.go @@ -712,11 +712,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/dashboard_client.go b/extension/dashboard_client.go index c2a8a651..5ff0950f 100644 --- a/extension/dashboard_client.go +++ b/extension/dashboard_client.go @@ -95,7 +95,7 @@ func (p *proxyContributor) fetchFragment(ctx context.Context, target, basePath, target = appendQuery(target, basePathQueryParam, basePath) target = appendQuery(target, pageBaseQueryParam, pageBase) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, http.NoBody) if err != nil { return nil, fmt.Errorf("authsome proxy: build request: %w", err) } diff --git a/plugins/social/github.go b/plugins/social/github.go index 7f53986c..c225f0e3 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 a99cd54f..3f4aedbc 100644 --- a/service.go +++ b/service.go @@ -272,7 +272,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 744c130c..ff6950da 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(),