From 3f6b7b66367ddb438fc89e941c0c69fc07ec417f Mon Sep 17 00:00:00 2001
From: Krispire Admin
Date: Tue, 2 Jun 2026 22:54:41 +0530
Subject: [PATCH] feat: account connect
---
internal/handler/auth.go | 62 +++++++-
internal/handler/oauth.go | 96 +++++++++++-
internal/model/user.go | 5 +
internal/repository/oauth_account_repo.go | 25 +++
internal/repository/user_repo.go | 26 +++
internal/server/server.go | 10 ++
internal/service/auth_service.go | 183 +++++++++++++++++++++-
pkg/oauth/state.go | 44 ++++--
8 files changed, 429 insertions(+), 22 deletions(-)
diff --git a/internal/handler/auth.go b/internal/handler/auth.go
index b8d09c3..7b5af28 100644
--- a/internal/handler/auth.go
+++ b/internal/handler/auth.go
@@ -53,6 +53,12 @@ type verifyReq struct {
Token string `json:"token"`
}
+type verifyLoginOTPReq struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ OTP string `json:"otp"`
+}
+
type setEmailReq struct {
Email string `json:"email"`
}
@@ -92,7 +98,14 @@ func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusCreated, res.User)
}
-// LogIn authenticates an existing user.
+// LogIn authenticates an existing user. The response is one of:
+//
+// - 200 OK with the User JSON — credentials verified, session cookie set,
+// frontend treats this as fully signed in.
+// - 202 Accepted with `{ "otp_required": true }` — credentials verified
+// but the account has been unverified for over 30 days. An OTP has been
+// emailed; frontend must collect it and call VerifyLoginOTP next. No
+// session cookie is set on this branch.
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
var req logInReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -112,6 +125,40 @@ func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
h.renderAuthErr(w, err)
return
}
+ if res.OTPRequired {
+ response.JSON(w, http.StatusAccepted, map[string]any{
+ "otp_required": true,
+ })
+ return
+ }
+ auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies)
+ response.JSON(w, http.StatusOK, res.Auth.User)
+}
+
+// VerifyLoginOTP completes a login that was gated by the stale-unverified
+// OTP path. Re-checks the password (the OTP alone isn't enough — an
+// attacker who steals the OTP email still needs the password), validates
+// the OTP, flips email_verified_at to now(), and sets the session cookie.
+func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) {
+ var req verifyLoginOTPReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ response.Error(w, http.StatusBadRequest, "invalid JSON body")
+ return
+ }
+ v := validator.New()
+ validator.Required(v, "email", req.Email)
+ validator.Required(v, "password", req.Password)
+ validator.Required(v, "otp", req.OTP)
+ if !v.Valid() {
+ response.Error(w, http.StatusBadRequest, v.Error())
+ return
+ }
+
+ res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP)
+ if err != nil {
+ h.renderAuthErr(w, err)
+ return
+ }
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.User)
}
@@ -260,6 +307,19 @@ func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, u)
}
+// LinkedProviders — protected — returns the OAuth provider names already
+// linked to the caller's account. Settings UI uses this to flip each
+// social-connect row between "Connect" and "Connected".
+func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) {
+ uid := middleware.UserID(r.Context())
+ providers, err := h.svc.LinkedProviders(r.Context(), uid)
+ if err != nil {
+ response.Error(w, http.StatusInternalServerError, "could not load linked providers")
+ return
+ }
+ response.JSON(w, http.StatusOK, map[string]any{"providers": providers})
+}
+
// ResendVerification — protected — refires the verification email for the
// currently-logged-in user.
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/handler/oauth.go b/internal/handler/oauth.go
index 455d873..65547e4 100644
--- a/internal/handler/oauth.go
+++ b/internal/handler/oauth.go
@@ -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"
diff --git a/internal/model/user.go b/internal/model/user.go
index 43930b3..5b5cf5a 100644
--- a/internal/model/user.go
+++ b/internal/model/user.go
@@ -62,6 +62,11 @@ type AuthToken struct {
const (
PurposePasswordReset = "password_reset"
PurposeEmailVerification = "email_verification"
+ // PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
+ // account has been unverified for >30 days. Verification happens
+ // per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
+ // offline by hash collision — only online against rate limits.
+ PurposeLoginOTP = "login_otp"
)
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
diff --git a/internal/repository/oauth_account_repo.go b/internal/repository/oauth_account_repo.go
index bdf7d47..5230a2c 100644
--- a/internal/repository/oauth_account_repo.go
+++ b/internal/repository/oauth_account_repo.go
@@ -44,6 +44,31 @@ func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, provide
return a, nil
}
+// ListProvidersForUser returns the distinct provider names linked to this
+// user, ordered alphabetically. Used by the settings UI to mark each
+// social-connect row as "Connected" vs "Connect".
+func (r *OAuthAccountRepo) ListProvidersForUser(ctx context.Context, userID string) ([]string, error) {
+ const q = `
+ SELECT DISTINCT provider
+ FROM oauth_accounts
+ WHERE user_id = $1
+ ORDER BY provider`
+ rows, err := r.pg.Pool.Query(ctx, q, userID)
+ if err != nil {
+ return nil, fmt.Errorf("list oauth providers: %w", err)
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var p string
+ if err := rows.Scan(&p); err != nil {
+ return nil, fmt.Errorf("scan oauth provider: %w", err)
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
// Link inserts a new oauth_accounts row tying a provider identity to an
// existing user. Idempotent: a second call for the same (provider, sub)
// returns the existing row instead of erroring on the unique constraint.
diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go
index 2137500..ed73fc2 100644
--- a/internal/repository/user_repo.go
+++ b/internal/repository/user_repo.go
@@ -380,6 +380,32 @@ func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string
return t, nil
}
+// FindValidForUser returns a non-used, non-expired token by user + hash +
+// purpose. Used by the OTP login flow where the keyspace is small (6
+// digits) — scoping by user_id prevents a global hash-collision lookup.
+func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, tokenHash, purpose string) (*model.AuthToken, error) {
+ const q = `
+ SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
+ FROM auth_tokens
+ WHERE user_id = $1
+ AND token_hash = $2
+ AND purpose = $3
+ AND used_at IS NULL
+ AND expires_at > now()
+ LIMIT 1`
+
+ t := &model.AuthToken{}
+ err := r.pg.Pool.QueryRow(ctx, q, userID, tokenHash, purpose).
+ Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrUserNotFound
+ }
+ return nil, fmt.Errorf("find token: %w", err)
+ }
+ return t, nil
+}
+
// Consume marks a token as used. After this call the token can never be
// honoured again, even if it has not yet expired.
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
diff --git a/internal/server/server.go b/internal/server/server.go
index 79d811f..d6655ec 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -503,6 +503,7 @@ func (s *Server) setupRouter() {
}
r.Post("/signup", s.authH.SignUp)
r.Post("/login", s.authH.LogIn)
+ r.Post("/verify-login-otp", s.authH.VerifyLoginOTP)
r.Post("/logout", s.authH.LogOut)
r.Post("/forgot-password", s.authH.ForgotPassword)
r.Post("/reset-password", s.authH.ResetPassword)
@@ -520,9 +521,18 @@ func (s *Server) setupRouter() {
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/me", s.authH.Me)
+ r.Get("/linked-providers", s.authH.LinkedProviders)
r.Post("/email", s.authH.SetEmail)
r.Post("/change-password", s.authH.ChangePassword)
r.Post("/resend-verification", s.authH.ResendVerification)
+ // Link-provider flow — requires the caller to be signed
+ // in; the state encodes their id so the callback links to
+ // THIS user instead of running the normal sign-in path.
+ // The callback itself is the shared /oauth/{provider}/callback
+ // above — the state's link_user_id field switches behaviour.
+ if s.oauthH != nil {
+ r.Get("/oauth/{provider}/link/start", s.oauthH.LinkStart)
+ }
})
})
diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go
index 3f104c8..055c7c1 100644
--- a/internal/service/auth_service.go
+++ b/internal/service/auth_service.go
@@ -10,6 +10,7 @@ import (
"context"
"crypto/rand"
"crypto/sha256"
+ "encoding/binary"
"encoding/hex"
"errors"
"fmt"
@@ -129,10 +130,32 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
return s.issueSession(u)
}
+// LoginResult is the two-state outcome of LogIn:
+//
+// - `Auth != nil` — credentials verified AND email is fine. Caller
+// sets the session cookie and returns the user.
+// - `OTPRequired == true` — credentials verified but the account has been
+// unverified for more than 30 days. An OTP has been emailed; the caller
+// must collect it from the user and call VerifyLoginOTP to complete the
+// sign-in. No session cookie is set yet.
+//
+// One of the two is always set on a non-error return.
+type LoginResult struct {
+ Auth *AuthResult
+ OTPRequired bool
+}
+
+// staleUnverifiedAge is how long an account can stay unverified before
+// every subsequent email/password login is gated on an OTP. Anything
+// shorter and a normal user who simply hasn't checked their inbox yet
+// would hit the gate; longer and abandoned-but-not-verified accounts
+// remain a forever-credential. 30 days is the documented requirement.
+const staleUnverifiedAge = 30 * 24 * time.Hour
+
// LogIn authenticates a user by email + password. The error returned for an
// unknown email is intentionally identical to a wrong password — never leak
-// which one was wrong.
-func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
+// which one was wrong. May return early with OTPRequired=true; see LoginResult.
+func (s *AuthService) LogIn(ctx context.Context, email, password string) (*LoginResult, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
@@ -148,7 +171,97 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
- return s.issueSession(u)
+
+ // OTP gate — unverified email AND account is older than the grace
+ // window. Mint an OTP and signal the caller to collect it. We do NOT
+ // issue a session here.
+ if u.EmailVerifiedAt == nil && time.Since(u.CreatedAt) > staleUnverifiedAge {
+ if u.Email == nil || *u.Email == "" {
+ // No email to send to → fall through to "invalid credentials"
+ // rather than expose this corner case.
+ return nil, ErrInvalidCredentials
+ }
+ if err := s.sendLoginOTP(ctx, u); err != nil {
+ s.logger.Warn("login otp send failed",
+ zap.String("user", u.ID), zap.Error(err))
+ return nil, fmt.Errorf("send login otp: %w", err)
+ }
+ return &LoginResult{OTPRequired: true}, nil
+ }
+
+ res, err := s.issueSession(u)
+ if err != nil {
+ return nil, err
+ }
+ return &LoginResult{Auth: res}, nil
+}
+
+// VerifyLoginOTP completes a login that was paused by the stale-unverified
+// OTP gate. Re-checks the password (so an attacker who steals an OTP email
+// still can't sign in without the password), validates the OTP, flips
+// email_verified_at to now(), and issues a fresh session.
+func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp string) (*AuthResult, error) {
+ u, err := s.users.FindByEmail(ctx, email)
+ if err != nil {
+ return nil, ErrInvalidCredentials
+ }
+ if u.PasswordHash == nil {
+ return nil, ErrInvalidCredentials
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
+ return nil, ErrInvalidCredentials
+ }
+
+ otpHash := hashToken(otp)
+ tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
+ if err != nil {
+ return nil, ErrInvalidOrExpiredLink
+ }
+ if err := s.tokens.Consume(ctx, tok.ID); err != nil {
+ return nil, err
+ }
+ if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
+ return nil, err
+ }
+ u2, err := s.users.FindByID(ctx, u.ID)
+ if err != nil {
+ return nil, err
+ }
+ return s.issueSession(u2)
+}
+
+// sendLoginOTP mints a fresh 6-digit code, invalidates any older pending
+// codes, stores the SHA-256 hash, and emails the raw code to the user.
+func (s *AuthService) sendLoginOTP(ctx context.Context, u *model.User) error {
+ if u.Email == nil || *u.Email == "" {
+ return errors.New("no email on file")
+ }
+ _ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeLoginOTP)
+
+ otp, hash, err := newOTP()
+ if err != nil {
+ return err
+ }
+ if _, err := s.tokens.Create(ctx, u.ID, model.PurposeLoginOTP, hash, 10*time.Minute); err != nil {
+ return err
+ }
+ body := loginOTPEmail(u.Name, otp)
+ return s.mail.Send(*u.Email, "Your login code — Prabhupāda Says", body)
+}
+
+// newOTP returns a freshly-generated 6-digit code + its SHA-256 hex hash.
+// Modulo bias on a uniform 32-bit source is negligible for the 1M keyspace
+// (10⁶ / 2³² ≈ 4 parts in 10⁴) and irrelevant against rate-limited online
+// guessing, which is the only attack model we care about for this size.
+func newOTP() (raw, hash string, err error) {
+ var b [4]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", "", err
+ }
+ n := binary.BigEndian.Uint32(b[:]) % 1_000_000
+ raw = fmt.Sprintf("%06d", n)
+ hash = hashToken(raw)
+ return raw, hash, nil
}
// OAuthProfile is the provider-agnostic profile shape consumed by
@@ -168,6 +281,49 @@ type OAuthProfile struct {
// startup). Handler maps to 503.
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
+// ErrOAuthAccountClaimed is returned by LinkOAuthToCurrentUser when the
+// provider identity is already linked to a different user. We refuse to
+// move the linkage — the rightful owner of the OAuth account at the
+// provider can sign in via the normal flow.
+var ErrOAuthAccountClaimed = errors.New("oauth account already linked to another user")
+
+// LinkOAuthToCurrentUser ties an OAuth provider identity to a specific
+// already-authenticated user (settings → Connect flow). Refuses to move
+// the linkage if some other user already claims it.
+//
+// Email policy: the user's primary email is NEVER touched by this call.
+// The provider's email is stored on the oauth_accounts row only for
+// reference (display in the future, audit). If the user wants to change
+// their primary email they have to do it explicitly via SetEmail.
+func (s *AuthService) LinkOAuthToCurrentUser(ctx context.Context, userID string, prof OAuthProfile) error {
+ if s.oauth == nil {
+ return ErrOAuthUnavailable
+ }
+
+ // If this (provider, sub) is already linked, the only valid case is
+ // that it's linked to the SAME user we're trying to link to. Any other
+ // user owning this linkage means the OAuth account was already claimed
+ // (typically the user mis-clicked or it's a different person on a
+ // shared device) — refuse rather than silently steal it.
+ if existing, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
+ if existing.UserID != userID {
+ return ErrOAuthAccountClaimed
+ }
+ // Same user — idempotent: refresh the denormalised profile fields
+ // and return success.
+ _, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
+ optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
+ return err
+ } else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
+ return err
+ }
+
+ // Fresh link.
+ _, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
+ optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
+ return err
+}
+
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
//
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
@@ -312,6 +468,18 @@ func optStr(s string) *string {
return &s
}
+// LinkedProviders returns the OAuth provider names already linked to the
+// caller's account ("google", "facebook"). Used by the settings UI to flip
+// the per-provider rows between "Connect" and "Connected".
+func (s *AuthService) LinkedProviders(ctx context.Context, userID string) ([]string, error) {
+ if s.oauth == nil {
+ // OAuth repo not wired (no Postgres). No-op rather than erroring —
+ // settings just shows everything as un-connected.
+ return []string{}, nil
+ }
+ return s.oauth.ListProvidersForUser(ctx, userID)
+}
+
// ChangePassword sets the caller's password, in two modes:
//
// 1. Account HAS a password — caller must supply the current one. We
@@ -616,6 +784,15 @@ clicking the link below:
shown at the top of the site after you log in.`, htmlEscape(name), link, link)
}
+func loginOTPEmail(name, otp string) string {
+ return fmt.Sprintf(`Hari Bol %s,
+Your account on Prabhupāda Says has been unverified for a
+while, so we need a quick one-time code before signing you in:
+%s
+This code expires in 10 minutes. If you didn't try to sign in, you can
+safely ignore this email.
`, htmlEscape(name), otp)
+}
+
func htmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&",
diff --git a/pkg/oauth/state.go b/pkg/oauth/state.go
index 2b70aaf..a539f64 100644
--- a/pkg/oauth/state.go
+++ b/pkg/oauth/state.go
@@ -19,11 +19,16 @@ import (
// `Next` is the post-login redirect path on the frontend. We sign it (along
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
// user to an arbitrary URL after sign-in.
+//
+// `LinkUserID` is set only by the /link/start endpoint — it pins the OAuth
+// callback to the user who initiated the link. An empty value means the
+// callback should run the normal sign-in / sign-up flow instead.
type statePayload struct {
- Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
- Next string `json:"u"` // safe path, validated by handler
- IssuedAt int64 `json:"t"` // unix seconds
- Provider string `json:"p"` // "google" | "facebook"
+ Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
+ Next string `json:"u"` // safe path, validated by handler
+ IssuedAt int64 `json:"t"` // unix seconds
+ Provider string `json:"p"` // "google" | "facebook"
+ LinkUserID string `json:"l,omitempty"` // user id to link the provider to, empty for sign-in flow
}
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
@@ -34,16 +39,20 @@ const stateMaxAge = 10 * time.Minute
// EncodeState signs and packs a fresh state value bound to the given provider
// and post-login redirect path. The returned string is what goes into both
// the `state` query param and the cookie.
-func EncodeState(secret []byte, provider, next string) (string, error) {
+//
+// `linkUserID` pins the callback to a specific user (link-to-current-user
+// flow). Pass "" for the normal sign-in flow.
+func EncodeState(secret []byte, provider, next, linkUserID string) (string, error) {
nonceB := make([]byte, 16)
if _, err := rand.Read(nonceB); err != nil {
return "", fmt.Errorf("state nonce: %w", err)
}
p := statePayload{
- Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
- Next: next,
- IssuedAt: time.Now().Unix(),
- Provider: provider,
+ Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
+ Next: next,
+ IssuedAt: time.Now().Unix(),
+ Provider: provider,
+ LinkUserID: linkUserID,
}
body, err := json.Marshal(p)
if err != nil {
@@ -59,30 +68,33 @@ func EncodeState(secret []byte, provider, next string) (string, error) {
// DecodeState verifies the signature, checks the freshness window, and
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
// the handler maps it to a single error path.
-func DecodeState(secret []byte, raw string) (provider, next string, err error) {
+//
+// `linkUserID` is empty for sign-in flows and set for link-to-current-user
+// flows — callers should branch on it.
+func DecodeState(secret []byte, raw string) (provider, next, linkUserID string, err error) {
parts := strings.SplitN(raw, ".", 2)
if len(parts) != 2 {
- return "", "", ErrInvalidState
+ return "", "", "", ErrInvalidState
}
bodyEnc, sigEnc := parts[0], parts[1]
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(bodyEnc))
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
- return "", "", ErrInvalidState
+ return "", "", "", ErrInvalidState
}
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
if err != nil {
- return "", "", ErrInvalidState
+ return "", "", "", ErrInvalidState
}
var p statePayload
if err := json.Unmarshal(body, &p); err != nil {
- return "", "", ErrInvalidState
+ return "", "", "", ErrInvalidState
}
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
- return "", "", ErrInvalidState
+ return "", "", "", ErrInvalidState
}
- return p.Provider, p.Next, nil
+ return p.Provider, p.Next, p.LinkUserID, nil
}
// ErrInvalidState is returned whenever signature or freshness checks fail.