feat: fb and google oauth

This commit is contained in:
Krispire Admin
2026-06-02 12:14:11 +05:30
parent 72f4e6527c
commit 0a348593b1
14 changed files with 872 additions and 36 deletions
+213
View File
@@ -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)
}
+91
View File
@@ -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")