feat: fb and google oauth
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// stateCookieName is the short-lived cookie that pairs with the state query
|
||||
// param so callback can verify "this redirect was started by us." Scoped to
|
||||
// /api/v1/auth/oauth so it never leaks into other handlers.
|
||||
const stateCookieName = "spsays_oauth_state"
|
||||
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
|
||||
|
||||
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
|
||||
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
|
||||
type OAuthHandler struct {
|
||||
svc *service.AuthService
|
||||
cookies auth.CookieOptions
|
||||
providers oauth.Registry
|
||||
stateSecret []byte // HMAC key for signing state — derived from JWT secret
|
||||
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
|
||||
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewOAuthHandler(
|
||||
svc *service.AuthService,
|
||||
cookies auth.CookieOptions,
|
||||
providers oauth.Registry,
|
||||
stateSecret []byte,
|
||||
bffPublicURL, frontendURL string,
|
||||
logger *zap.Logger,
|
||||
) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
svc: svc,
|
||||
cookies: cookies,
|
||||
providers: providers,
|
||||
stateSecret: stateSecret,
|
||||
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
|
||||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start kicks off the OAuth round-trip. It generates a signed state value,
|
||||
// stores it in a short-lived cookie, and 302s the browser to the provider's
|
||||
// AuthCodeURL. `next` query param carries the post-login redirect path on
|
||||
// the frontend (defaults to "/u/me").
|
||||
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
prov, err := h.providers.Get(providerName)
|
||||
if err != nil {
|
||||
http.Error(w, "unknown provider", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||
|
||||
state, err := oauth.EncodeState(h.stateSecret, providerName, next)
|
||||
if err != nil {
|
||||
h.logger.Error("oauth encode state", zap.Error(err))
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// State cookie matches the auth cookie's SameSite/Secure profile so the
|
||||
// browser sends it back on the callback redirect. Path-scoped so it
|
||||
// doesn't ride along on unrelated requests.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state,
|
||||
Path: "/api/v1/auth/oauth",
|
||||
Domain: h.cookies.Domain,
|
||||
MaxAge: stateCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.cookies.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
cfg := prov.Config(h.callbackURL(providerName))
|
||||
authURL := cfg.AuthCodeURL(state)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback finishes the round-trip. Verifies state, exchanges the code,
|
||||
// fetches the profile, signs the user in, sets the session cookie, and
|
||||
// redirects back to the frontend `next` path.
|
||||
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
prov, err := h.providers.Get(providerName)
|
||||
if err != nil {
|
||||
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Provider returned an error (user denied, etc.).
|
||||
if e := r.URL.Query().Get("error"); e != "" {
|
||||
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
|
||||
return
|
||||
}
|
||||
|
||||
// State must be present in BOTH cookie and query param, and they must
|
||||
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
|
||||
// query alone → an attacker could craft any signed state).
|
||||
cookie, err := r.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
h.redirectErr(w, r, "/login?oauth_error=missing_state")
|
||||
return
|
||||
}
|
||||
stateQ := r.URL.Query().Get("state")
|
||||
if stateQ == "" || stateQ != cookie.Value {
|
||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||
return
|
||||
}
|
||||
// Clear the state cookie regardless of what happens next.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: "",
|
||||
Path: "/api/v1/auth/oauth",
|
||||
Domain: h.cookies.Domain,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
Secure: h.cookies.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
stateProvider, next, err := oauth.DecodeState(h.stateSecret, stateQ)
|
||||
if err != nil || stateProvider != providerName {
|
||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
h.redirectErr(w, r, "/login?oauth_error=missing_code")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := prov.Config(h.callbackURL(providerName))
|
||||
tok, err := cfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
|
||||
return
|
||||
}
|
||||
|
||||
prof, err := prov.FetchProfile(r.Context(), tok)
|
||||
if err != nil {
|
||||
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
|
||||
Provider: prof.Provider,
|
||||
ProviderUserID: prof.ProviderUserID,
|
||||
Email: prof.Email,
|
||||
EmailVerified: prof.EmailVerified,
|
||||
Name: prof.Name,
|
||||
AvatarURL: prof.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrOAuthEmailMissing):
|
||||
h.redirectErr(w, r, "/login?oauth_error=email_required")
|
||||
case errors.Is(err, service.ErrOAuthUnavailable):
|
||||
h.redirectErr(w, r, "/login?oauth_error=unavailable")
|
||||
default:
|
||||
h.logger.Error("oauth sign-in", zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=server")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
|
||||
}
|
||||
|
||||
// callbackURL composes the redirect_uri the provider's console must allow-list.
|
||||
func (h *OAuthHandler) callbackURL(provider string) string {
|
||||
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||
}
|
||||
|
||||
// redirectErr sends the user back to the frontend with a single error query
|
||||
// param so the login page can render something meaningful. No internal details
|
||||
// leak through this path.
|
||||
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
|
||||
}
|
||||
|
||||
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
|
||||
// containing a scheme, host, or whitespace is dropped to the default. This is
|
||||
// the same anti-open-redirect treatment we apply on the share-shortener.
|
||||
func sanitizeNext(raw string) string {
|
||||
const def = "/u/me"
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return def
|
||||
}
|
||||
if strings.ContainsAny(raw, " \t\r\n") {
|
||||
return def
|
||||
}
|
||||
if len(raw) > 512 {
|
||||
return def
|
||||
}
|
||||
return raw
|
||||
}
|
||||
Reference in New Issue
Block a user