feat: fb and google oauth
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
"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
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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" }
|
||||
|
||||
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/v18.0/dialog/oauth",
|
||||
TokenURL: "https://graph.facebook.com/v18.0/oauth/access_token",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const url = "https://graph.facebook.com/v18.0/me?fields=id,email,name,picture.type(large)"
|
||||
body, err := httpGet(ctx, tok, url)
|
||||
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")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// statePayload is the JSON shape we round-trip through the OAuth state
|
||||
// parameter. The cookie holds the same encoded blob; on callback we compare
|
||||
// the two byte-for-byte before trusting either.
|
||||
//
|
||||
// `Next` is the post-login redirect path on the frontend. We sign it (along
|
||||
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
|
||||
// user to an arbitrary URL after sign-in.
|
||||
type statePayload struct {
|
||||
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
|
||||
Next string `json:"u"` // safe path, validated by handler
|
||||
IssuedAt int64 `json:"t"` // unix seconds
|
||||
Provider string `json:"p"` // "google" | "facebook"
|
||||
}
|
||||
|
||||
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
|
||||
// generous — typical OAuth round-trips finish in seconds. Longer than this
|
||||
// and we discard the state to keep the replay window small.
|
||||
const stateMaxAge = 10 * time.Minute
|
||||
|
||||
// EncodeState signs and packs a fresh state value bound to the given provider
|
||||
// and post-login redirect path. The returned string is what goes into both
|
||||
// the `state` query param and the cookie.
|
||||
func EncodeState(secret []byte, provider, next string) (string, error) {
|
||||
nonceB := make([]byte, 16)
|
||||
if _, err := rand.Read(nonceB); err != nil {
|
||||
return "", fmt.Errorf("state nonce: %w", err)
|
||||
}
|
||||
p := statePayload{
|
||||
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
|
||||
Next: next,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
Provider: provider,
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
bodyEnc := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(bodyEnc))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return bodyEnc + "." + sig, nil
|
||||
}
|
||||
|
||||
// DecodeState verifies the signature, checks the freshness window, and
|
||||
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
|
||||
// the handler maps it to a single error path.
|
||||
func DecodeState(secret []byte, raw string) (provider, next string, err error) {
|
||||
parts := strings.SplitN(raw, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", ErrInvalidState
|
||||
}
|
||||
bodyEnc, sigEnc := parts[0], parts[1]
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(bodyEnc))
|
||||
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
|
||||
return "", "", ErrInvalidState
|
||||
}
|
||||
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
|
||||
if err != nil {
|
||||
return "", "", ErrInvalidState
|
||||
}
|
||||
var p statePayload
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
return "", "", ErrInvalidState
|
||||
}
|
||||
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
|
||||
return "", "", ErrInvalidState
|
||||
}
|
||||
return p.Provider, p.Next, nil
|
||||
}
|
||||
|
||||
// ErrInvalidState is returned whenever signature or freshness checks fail.
|
||||
// We deliberately return one error so handlers don't leak whether a request
|
||||
// was tampered or just stale.
|
||||
var ErrInvalidState = errors.New("invalid or expired oauth state")
|
||||
Reference in New Issue
Block a user