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.