diff --git a/pkg/oauth/oauth.go b/pkg/oauth/oauth.go index bb4554e..54a2d6c 100644 --- a/pkg/oauth/oauth.go +++ b/pkg/oauth/oauth.go @@ -17,7 +17,9 @@ import ( "errors" "fmt" "io" + "log" "net/http" + "net/url" "strings" "golang.org/x/oauth2" @@ -150,6 +152,11 @@ type facebook struct{ id, secret string } func (f *facebook) Name() string { return "facebook" } +// Bump in lockstep with whatever version your Meta app is configured for +// (Settings → Basic → API Version). Stay on a single supported version so +// /me responses match the documented schema. +const facebookGraphVersion = "v25.0" + func (f *facebook) Config(redirectURL string) *oauth2.Config { return &oauth2.Config{ ClientID: f.id, @@ -157,8 +164,8 @@ func (f *facebook) Config(redirectURL string) *oauth2.Config { RedirectURL: redirectURL, Scopes: []string{"email", "public_profile"}, Endpoint: oauth2.Endpoint{ - AuthURL: "https://www.facebook.com/v18.0/dialog/oauth", - TokenURL: "https://graph.facebook.com/v18.0/oauth/access_token", + AuthURL: "https://www.facebook.com/" + facebookGraphVersion + "/dialog/oauth", + TokenURL: "https://graph.facebook.com/" + facebookGraphVersion + "/oauth/access_token", }, } } @@ -187,8 +194,18 @@ type facebookMe struct { } func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) { - const url = "https://graph.facebook.com/v18.0/me?fields=id,email,name,picture.type(large)" - body, err := httpGet(ctx, tok, url) + // Facebook Graph API: pass the token as ?access_token=... (their + // canonical mode) rather than via Bearer header. Bearer works most + // of the time but is documented as a secondary mode and has been + // reported to silently drop fields in some Graph versions. + params := url.Values{ + "fields": {"id,email,name,picture.type(large)"}, + "access_token": {tok.AccessToken}, + } + reqURL := "https://graph.facebook.com/" + facebookGraphVersion + + "/me?" + params.Encode() + + body, err := plainGet(ctx, reqURL) if err != nil { return nil, fmt.Errorf("facebook /me: %w", err) } @@ -199,6 +216,12 @@ func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profil if u.ID == "" { return nil, fmt.Errorf("facebook /me: empty id") } + // If email permission was granted but FB still didn't return one, + // log the raw response so the operator can diagnose (commonly the + // user's FB account email is unverified — Graph API omits it silently). + if u.Email == "" { + log.Printf("oauth/facebook: /me returned no email; raw=%s", truncate(body, 512)) + } return &Profile{ Provider: "facebook", ProviderUserID: u.ID, @@ -209,9 +232,35 @@ func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profil }, nil } -// httpGet performs a token-authenticated GET and returns the response body. -// The provider tokens include their auth header already (Bearer for Google, -// query-param for Facebook), so we let oauth2.Client handle it. +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} + +// plainGet performs an unauthenticated GET (the caller is expected to have +// already embedded any access token in the URL). Used by Facebook because +// Graph API treats `?access_token=` as the canonical auth mode. +func plainGet(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + return io.ReadAll(resp.Body) +} + +// httpGet performs a Bearer-authenticated GET — used by Google. Facebook +// uses plainGet with the token in a query param instead. func httpGet(ctx context.Context, tok *oauth2.Token, url string) ([]byte, error) { cli := oauth2.NewClient(ctx, oauth2.StaticTokenSource(tok)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)