package api import ( "encoding/json" "net/http" "os" "path/filepath" "strconv" "time" "geniusrun/backend/internal/auth" "geniusrun/backend/internal/garmin" applog "geniusrun/backend/internal/log" ) // setupSession is a temporary, not-yet-persisted Garmin authentication // attempt made during onboarding, before any users/profile row exists -- // keyed by OIDC subject (the only stable identifier available pre-account) // rather than a user id. Closed (never promoted into Server.userClient) once // /api/setup/complete actually creates the account -- its Client's // garmin.Config.TokenStorePath is permanently pinned to the ephemeral // setup/{hash} directory, so reusing the object after that directory is // renamed to the permanent {userID} one would respawn against a stale path // the next time anything closes and restarts its subprocess; a later // garminFor(ctx, userID) call builds a fresh client with the correct path // instead. Otherwise evicted lazily (the next setup-endpoint touch for that // subject checks staleness first) once idle past SessionConfig.SetupTimeout. type setupSession struct { Client garmin.Client Email, Password string Status garmin.AuthStatus Message string LastUsed time.Time } // 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 clientFor(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.setupSession, claims.Sub) s.userAuthStatus[userID] = sess.Status s.userAuthMessage[userID] = sess.Message s.mu.Unlock() if s.ClientConfig.TokenStorePath != "" { oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub) newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10)) // A stale {userID} directory can survive from a previous account // with the same id -- pre-production the DB file is freely deleted // and recreated (ids restart at 1) while the token-store root lives // on, and os.Rename refuses to replace a non-empty directory. The // just-validated setup session must win, so clear the target first. if err := os.RemoveAll(newDir); err != nil { applog.App().Error("remove stale token store dir", "user_id", userID, "error", err) } if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) { applog.App().Error("rename setup token store dir", "user_id", userID, "error", err) } } writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName}) } // setupSessionFor returns sub's in-progress ephemeral Garmin session, if // any and not stale. A stale session is closed and evicted first, so the // caller always either gets a fresh, live session or none. func (s *Server) setupSessionFor(sub string) (*setupSession, bool) { s.mu.Lock() defer s.mu.Unlock() sess, ok := s.setupSession[sub] if !ok { return nil, false } if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout { sess.Client.Close() delete(s.setupSession, sub) if s.ClientConfig.TokenStorePath != "" { os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)) } return nil, false } return sess, true } // replaceSetupSession closes and replaces sub's ephemeral Garmin session // (if any) with a freshly built one for the given credentials -- same // "close old, spawn new" semantics as garmin.Client.UpdateCredentials. func (s *Server) replaceSetupSession(sub, email, password string) *setupSession { s.mu.Lock() old, ok := s.setupSession[sub] s.mu.Unlock() if ok { old.Client.Close() } cfg := s.ClientConfig cfg.GarminEmail = email cfg.GarminPassword = password if cfg.TokenStorePath != "" { cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub) } sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()} s.mu.Lock() s.setupSession[sub] = sess s.mu.Unlock() return sess } // recordSetupAuthResult updates sub's ephemeral session after a login or // MFA attempt. A no-op if the session is gone (e.g. evicted concurrently). func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) { s.mu.Lock() defer s.mu.Unlock() if sess, ok := s.setupSession[sub]; ok { sess.Status = res.Status sess.Message = res.Message sess.LastUsed = time.Now() } } // removeSetupSession closes and drops sub's ephemeral Garmin session, if // any, and best-effort removes its token-store directory. Used when // abandoning onboarding (logout). handleSetupComplete closes the client the // same way but keeps (renames) the directory instead of removing it, since // that's the real, now-permanent session. func (s *Server) removeSetupSession(sub string) { s.mu.Lock() sess, ok := s.setupSession[sub] delete(s.setupSession, sub) s.mu.Unlock() if !ok { return } sess.Client.Close() if s.ClientConfig.TokenStorePath != "" { os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)) } }