Every log.Printf/Fatalf becomes a structured slog call through internal/log: request handlers use the request-scoped logger (applog.FromContext, carrying request_id), startup failures exit via a fatal() helper that still emits JSON, and the seedsample/dumpschema CLIs bootstrap the same JSON logger. Stale client_test expectations aligned with the refactored wrapper-call logging (message casing, result attr, ERROR level). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
282 lines
10 KiB
Go
282 lines
10 KiB
Go
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"
|
|
)
|
|
|
|
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
|
// survives without being touched (login, MFA, or complete) before it's
|
|
// evicted -- long enough to check email for an MFA code, short enough that
|
|
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
|
const setupSessionIdleTimeout = 15 * time.Minute
|
|
|
|
// 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 setupSessionIdleTimeout.
|
|
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))
|
|
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
|
applog.FromContext(r.Context()).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) > setupSessionIdleTimeout {
|
|
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))
|
|
}
|
|
}
|