2026-07-25 17:55:13 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
2026-07-26 13:30:51 +02:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
2026-08-04 16:04:18 +02:00
|
|
|
"time"
|
2026-07-25 17:55:13 +02:00
|
|
|
|
|
|
|
|
"geniusrun/backend/internal/auth"
|
2026-07-26 13:30:51 +02:00
|
|
|
"geniusrun/backend/internal/garmin"
|
2026-08-04 16:33:26 +02:00
|
|
|
applog "geniusrun/backend/internal/log"
|
2026-07-25 17:55:13 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
// 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
|
2026-08-04 18:17:24 +02:00
|
|
|
// subject checks staleness first) once idle past SessionConfig.SetupTimeout.
|
2026-08-04 16:04:18 +02:00
|
|
|
type setupSession struct {
|
|
|
|
|
Client garmin.Client
|
|
|
|
|
Email, Password string
|
|
|
|
|
Status garmin.AuthStatus
|
|
|
|
|
Message string
|
|
|
|
|
LastUsed time.Time
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 13:30:51 +02:00
|
|
|
// 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
|
2026-07-26 21:23:24 +02:00
|
|
|
// credentials, marks it connected, closes the ephemeral client, and
|
|
|
|
|
// renames its token-store directory into the permanent per-user path --
|
2026-08-04 16:04:18 +02:00
|
|
|
// the next real clientFor(ctx, userID) builds a fresh client from scratch
|
2026-07-26 21:23:24 +02:00
|
|
|
// 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).
|
2026-07-26 13:30:51 +02:00
|
|
|
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
2026-07-25 17:55:13 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 13:30:51 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:55:13 +02:00
|
|
|
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-26 13:30:51 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 21:23:24 +02:00
|
|
|
// 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()
|
|
|
|
|
|
2026-07-26 13:30:51 +02:00
|
|
|
s.mu.Lock()
|
2026-08-04 16:04:18 +02:00
|
|
|
delete(s.setupSession, claims.Sub)
|
2026-07-26 13:30:51 +02:00
|
|
|
s.userAuthStatus[userID] = sess.Status
|
|
|
|
|
s.userAuthMessage[userID] = sess.Message
|
|
|
|
|
s.mu.Unlock()
|
|
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
if s.ClientConfig.TokenStorePath != "" {
|
|
|
|
|
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
|
|
|
|
|
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
// 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("api.Server", "handleSetupComplete").Error("remove stale token store dir", "user_id", userID, "error", err)
|
|
|
|
|
}
|
2026-07-26 13:30:51 +02:00
|
|
|
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
|
|
|
applog.App("api.Server", "handleSetupComplete").Error("rename setup token store dir", "user_id", userID, "error", err)
|
2026-07-26 13:30:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 17:55:13 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
|
|
|
|
}
|
2026-08-04 16:04:18 +02:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-04 18:17:24 +02:00
|
|
|
if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout {
|
2026-08-04 16:04:18 +02:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|