feat(api): defer account creation until Garmin actually connects
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user