Files
geniusrun/backend/internal/api/setup.go
Christophe Vila a12fe3e810 fix(api): stop promoting the ephemeral Garmin client after setup completes
handleSetupComplete reused the onboarding client object as-is in the
permanent per-user cache, but its garmin.Config.TokenStorePath was fixed
at construction to the ephemeral setup/{hash} directory and never
corrected after that directory was renamed to the permanent {userID}
path. The next respawn of that same client (e.g. any Profile-page save,
which unconditionally calls UpdateCredentials) wrote a fresh token file
back under setup/{hash}, forcing a real re-login/MFA on the next Garmin
connect even though a valid session already existed under {userID}.

Close the ephemeral client instead and let the next garminFor call build
a fresh one against the correct, already-renamed directory.
2026-07-26 21:23:24 +02:00

178 lines
6.1 KiB
Go

package api
import (
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
)
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
// row exists yet at this point (see setupSession in server.go).
func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if _, found := userFromContext(r.Context()); found {
writeError(w, http.StatusConflict, "profile already exists for this account")
return
}
var body struct {
GarminEmail string `json:"garmin_email"`
GarminPassword string `json:"garmin_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.GarminEmail == "" || body.GarminPassword == "" {
writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required")
return
}
sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword)
res, err := sess.Client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordSetupAuthResult(claims.Sub, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
// handleSetupGarminMFA continues an in-progress ephemeral Garmin login
// (started by handleSetupGarminLogin) with an MFA code, on the same
// session/subprocess -- never replaces it.
func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
sess, ok := s.setupSessionFor(claims.Sub)
if !ok {
writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again")
return
}
res, err := sess.Client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordSetupAuthResult(claims.Sub, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
// handleSetupComplete is the single atomic commit point: only reachable
// once the ephemeral session for this subject last reported
// garmin.AuthSuccess. Provisions the account, persists the Garmin
// credentials, marks it connected, closes the ephemeral client, and
// renames its token-store directory into the permanent per-user path --
// the next real garminFor(ctx, userID) builds a fresh client from scratch
// against that now-permanent directory, whose subprocess's lazy
// startup-login resumes the just-renamed, still-valid session without
// needing to re-authenticate (a cheap local token-store resume, not a
// fresh Garmin login).
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if _, found := userFromContext(r.Context()); found {
writeError(w, http.StatusConflict, "profile already exists for this account")
return
}
var body struct {
DisplayName string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.DisplayName == "" {
writeError(w, http.StatusBadRequest, "display_name is required")
return
}
sess, ok := s.setupSessionFor(claims.Sub)
if !ok || sess.Status != garmin.AuthSuccess {
writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup")
return
}
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
profile, err := s.DB.GetProfile(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
profile.GarminEmail = sess.Email
profile.GarminPassword = sess.Password
if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// The ephemeral client's own garmin.Config.TokenStorePath was set once,
// at construction time in replaceSetupSession, to the ephemeral
// setup/{hash} directory being renamed below -- there's no setter to
// correct it in place, so promoting this object into s.userGarmin would
// leave a client whose subprocess respawns (e.g. on the very next
// UpdateCredentials call from a Profile save) using that now-stale
// path, recreating a setup/{hash} directory next to the real one.
// Closing it here and leaving s.userGarmin empty for this user makes
// the next garminFor(ctx, userID) call build a fresh client against the
// correct, just-renamed {userID} directory instead -- its subprocess's
// lazy startup-login resumes that session without a real Garmin
// re-authentication.
sess.Client.Close()
s.mu.Lock()
delete(s.setupGarmin, claims.Sub)
s.userAuthStatus[userID] = sess.Status
s.userAuthMessage[userID] = sess.Message
s.mu.Unlock()
if s.GarminBase.TokenStorePath != "" {
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub)
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
log.Printf("api: rename setup token store dir for user %d: %v", userID, err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
}