Files
spsays-bff/pkg/oauth/oauth.go
T
2026-06-02 16:42:22 +05:30

281 lines
9.4 KiB
Go

// Package oauth wraps the third-party sign-in providers used by the BFF.
//
// Design: each provider is a small struct that knows three things —
// - its golang.org/x/oauth2.Config (client id / secret / scopes / endpoint)
// - how to fetch a normalised Profile from an access token
// - its registry name ("google", "facebook")
//
// The handler layer is provider-agnostic: it picks a Registry entry by URL
// param, generates state, redirects to AuthCodeURL, then on callback
// exchanges the code and calls FetchProfile. The service layer then takes
// the Profile and turns it into a session (find-or-create user + JWT).
package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"golang.org/x/oauth2"
googleEndpoint "golang.org/x/oauth2/google"
)
// Profile is the normalised shape we extract from every provider.
//
// Email may be empty when the user denied the email scope (rare on Google,
// possible on Facebook accounts with no verified email). Callers must treat
// an empty email as a hard error — we use email as a fallback link key.
type Profile struct {
Provider string // "google" | "facebook"
ProviderUserID string // sub (Google) / id (Facebook)
Email string // lowercased
EmailVerified bool // Google sets this; Facebook does not — default true
Name string // display name, may be empty
AvatarURL string // absolute URL, may be empty
}
// Provider is the per-vendor adapter. Stateless after construction.
type Provider interface {
Name() string
Config(redirectURL string) *oauth2.Config
// AuthCodeOptions returns extra parameters appended to AuthCodeURL.
// Used by Facebook to set `auth_type=rerequest` so a user who
// previously declined the email permission gets re-prompted on the
// next sign-in attempt (Facebook silently re-uses prior declines
// otherwise). Most providers return nil.
AuthCodeOptions() []oauth2.AuthCodeOption
FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error)
}
// Registry maps provider name → Provider. Built from config at server start;
// only providers with both client ID and secret set become available.
type Registry map[string]Provider
// NewRegistry builds the live registry. Missing creds for a provider simply
// omit it — the start handler returns 404 for unconfigured providers.
func NewRegistry(cfg Credentials) Registry {
r := Registry{}
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
r["google"] = &google{id: cfg.GoogleClientID, secret: cfg.GoogleClientSecret}
}
if cfg.FacebookClientID != "" && cfg.FacebookClientSecret != "" {
r["facebook"] = &facebook{id: cfg.FacebookClientID, secret: cfg.FacebookClientSecret}
}
return r
}
// Credentials is the subset of app config the registry needs.
type Credentials struct {
GoogleClientID string
GoogleClientSecret string
FacebookClientID string
FacebookClientSecret string
}
// Has reports whether the registry knows the named provider.
func (r Registry) Has(name string) bool { _, ok := r[name]; return ok }
// Get returns the provider, or ErrUnknownProvider.
func (r Registry) Get(name string) (Provider, error) {
p, ok := r[name]
if !ok {
return nil, ErrUnknownProvider
}
return p, nil
}
// ErrUnknownProvider is returned when the URL param doesn't match any
// configured provider.
var ErrUnknownProvider = errors.New("unknown oauth provider")
// --- Google ---
type google struct{ id, secret string }
func (g *google) Name() string { return "google" }
func (g *google) Config(redirectURL string) *oauth2.Config {
return &oauth2.Config{
ClientID: g.id,
ClientSecret: g.secret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: googleEndpoint.Endpoint,
}
}
func (g *google) AuthCodeOptions() []oauth2.AuthCodeOption { return nil }
// googleUserinfo is the subset of the v3 userinfo response we read. The
// full payload has more fields (locale, given_name, family_name) we don't
// currently need.
type googleUserinfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func (g *google) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
const url = "https://openidconnect.googleapis.com/v1/userinfo"
body, err := httpGet(ctx, tok, url)
if err != nil {
return nil, fmt.Errorf("google userinfo: %w", err)
}
var u googleUserinfo
if err := json.Unmarshal(body, &u); err != nil {
return nil, fmt.Errorf("google userinfo decode: %w", err)
}
if u.Sub == "" {
return nil, fmt.Errorf("google userinfo: empty sub")
}
return &Profile{
Provider: "google",
ProviderUserID: u.Sub,
Email: strings.ToLower(strings.TrimSpace(u.Email)),
EmailVerified: u.EmailVerified,
Name: strings.TrimSpace(u.Name),
AvatarURL: u.Picture,
}, nil
}
// --- Facebook ---
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,
ClientSecret: f.secret,
RedirectURL: redirectURL,
Scopes: []string{"email", "public_profile"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.facebook.com/" + facebookGraphVersion + "/dialog/oauth",
TokenURL: "https://graph.facebook.com/" + facebookGraphVersion + "/oauth/access_token",
},
}
}
// AuthCodeOptions: `auth_type=rerequest` forces Facebook to re-prompt for
// any permissions the user previously declined (specifically `email`).
// Without this, a user who unchecked Email on their first attempt would
// be stuck — Facebook silently re-grants the old decline forever.
func (f *facebook) AuthCodeOptions() []oauth2.AuthCodeOption {
return []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("auth_type", "rerequest"),
}
}
// facebookMe captures the fields requested via ?fields=id,email,name,picture.
// picture is an envelope: { data: { url, ... } }.
type facebookMe struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
} `json:"picture"`
}
func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
// 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)
}
var u facebookMe
if err := json.Unmarshal(body, &u); err != nil {
return nil, fmt.Errorf("facebook /me decode: %w", err)
}
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,
Email: strings.ToLower(strings.TrimSpace(u.Email)),
EmailVerified: u.Email != "", // Facebook returns an email only if user granted the scope; we trust it
Name: strings.TrimSpace(u.Name),
AvatarURL: u.Picture.Data.URL,
}, nil
}
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)
if err != nil {
return nil, err
}
resp, err := cli.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)
}