281 lines
9.4 KiB
Go
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)
|
|
}
|