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, 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, 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 }