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, and promotes the already-authenticated // ephemeral client into the permanent per-user cache instead of discarding // it (no redundant re-authentication, no repeat MFA prompt, right after // signup). 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 } s.mu.Lock() delete(s.setupGarmin, claims.Sub) s.userGarmin[userID] = sess.Client 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}) }