feat(api): defer account creation until Garmin actually connects

This commit is contained in:
2026-07-26 13:30:51 +02:00
parent a1eaa42d42
commit 7d68af5b3c
6 changed files with 457 additions and 40 deletions

View File

@@ -4,6 +4,8 @@ package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -12,6 +14,7 @@ import (
"path/filepath"
"strconv"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -47,6 +50,7 @@ type Server struct {
userAuthStatus map[int64]garmin.AuthStatus
userAuthMessage map[int64]string
userSyncRunning map[int64]bool
setupGarmin map[string]*setupSession
}
// NewServer builds a Server.
@@ -59,6 +63,115 @@ func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, ga
userAuthStatus: map[int64]garmin.AuthStatus{},
userAuthMessage: map[int64]string{},
userSyncRunning: map[int64]bool{},
setupGarmin: map[string]*setupSession{},
}
}
// 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. Promoted into Server.userGarmin once
// /api/setup/complete actually creates the account; 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
}
// 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
// setupTokenStoreDir returns the token-store directory an ephemeral setup
// session for sub should use -- hashed rather than sub itself, since sub is
// an opaque string from the identity provider and using it verbatim in a
// filesystem path would be a directory-traversal risk if it ever contained
// path separators.
func setupTokenStoreDir(root, sub string) string {
h := sha256.Sum256([]byte(sub))
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
}
// 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.setupGarmin[sub]
if !ok {
return nil, false
}
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
sess.Client.Close()
delete(s.setupGarmin, sub)
if s.GarminBase.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.GarminBase.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.setupGarmin[sub]
s.mu.Unlock()
if ok {
old.Client.Close()
}
cfg := s.GarminBase
cfg.GarminEmail = email
cfg.GarminPassword = password
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)
}
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
s.mu.Lock()
s.setupGarmin[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.setupGarmin[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) -- NOT used during promotion in
// handleSetupComplete, which transfers ownership of the client (and
// renames the directory) instead of discarding them.
func (s *Server) removeSetupSession(sub string) {
s.mu.Lock()
sess, ok := s.setupGarmin[sub]
delete(s.setupGarmin, sub)
s.mu.Unlock()
if !ok {
return
}
sess.Client.Close()
if s.GarminBase.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
}
}
@@ -190,7 +303,13 @@ func (s *Server) Router() http.Handler {
r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout)
r.Post("/setup", s.handleSetup)
r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) {
r.Post("/login", s.handleSetupGarminLogin)
r.Post("/mfa", s.handleSetupGarminMFA)
})
})
r.Group(func(r chi.Router) {
r.Use(requireProvisionedUser)