// 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) }