Files
2026-06-02 22:54:41 +05:30

310 lines
10 KiB
Go

package handler
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"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, prov.AuthCodeOptions()...)
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, linkUserID, 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
}
// State pinned to a user id means the OAuth round-trip was started from
// /link/start (settings page). Dispatch to the link path so we link the
// provider to that user instead of running a fresh sign-in.
if linkUserID != "" {
h.finishLink(w, r, providerName, next, linkUserID, prof)
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.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)
}
// LinkStart kicks off the OAuth round-trip for an already-authenticated
// user who wants to add a provider to their account (settings → Connect).
// The caller's user id is signed into the state so the callback knows
// which account to link to instead of trying to find one by email.
func (h *OAuthHandler) LinkStart(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
}
uid := middleware.UserID(r.Context())
if uid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next := sanitizeNext(r.URL.Query().Get("next"))
state, err := oauth.EncodeState(h.stateSecret, providerName, next, uid)
if err != nil {
h.logger.Error("oauth encode link state", zap.Error(err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
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, prov.AuthCodeOptions()...)
http.Redirect(w, r, authURL, http.StatusFound)
}
// finishLink completes the link flow once the shared Callback has decoded
// state and fetched the provider profile. Bounces back to the frontend
// `next` path with `?link=ok|claimed|error` so the settings UI can render
// a meaningful message.
func (h *OAuthHandler) finishLink(
w http.ResponseWriter,
r *http.Request,
providerName, next, linkUserID string,
prof *oauth.Profile,
) {
err := h.svc.LinkOAuthToCurrentUser(r.Context(), linkUserID, service.OAuthProfile{
Provider: prof.Provider,
ProviderUserID: prof.ProviderUserID,
Email: prof.Email,
EmailVerified: prof.EmailVerified,
Name: prof.Name,
AvatarURL: prof.AvatarURL,
})
target := next
switch {
case err == nil:
target = appendQuery(next, "link", providerName+":ok")
case errors.Is(err, service.ErrOAuthAccountClaimed):
target = appendQuery(next, "link", providerName+":claimed")
default:
h.logger.Error("oauth link", zap.String("provider", providerName), zap.Error(err))
target = appendQuery(next, "link", providerName+":error")
}
http.Redirect(w, r, h.frontendURL+target, http.StatusFound)
}
// appendQuery adds a ?key=val (or &key=val) to a same-origin path, preserving
// any existing query string. Path must start with "/" — caller guarantees.
func appendQuery(path, key, value string) string {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return path + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value)
}
// 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
}