feat: account connect

This commit is contained in:
Krispire Admin
2026-06-02 22:54:41 +05:30
parent cf3a49458a
commit 3f6b7b6636
8 changed files with 429 additions and 22 deletions
+94 -2
View File
@@ -8,6 +8,7 @@ import (
"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"
@@ -65,7 +66,7 @@ func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
next := sanitizeNext(r.URL.Query().Get("next"))
state, err := oauth.EncodeState(h.stateSecret, providerName, 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)
@@ -134,7 +135,7 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
SameSite: http.SameSiteLaxMode,
})
stateProvider, next, err := oauth.DecodeState(h.stateSecret, stateQ)
stateProvider, next, linkUserID, err := oauth.DecodeState(h.stateSecret, stateQ)
if err != nil || stateProvider != providerName {
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
return
@@ -161,6 +162,14 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
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,
@@ -184,6 +193,89 @@ func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
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"