From e067eee33073c91c9697e4afa21227b1ae872880 Mon Sep 17 00:00:00 2001 From: VpEngOps Date: Sun, 6 Sep 2026 22:44:33 -0500 Subject: [PATCH] smcli: refresh-token support - sessions no longer die after ~1h The login grant requested offline_access but the issued refresh_token was parsed and discarded: every access token died with the ~1h Vaultwarden TTL, and consumers (all lanes) hit HTTP 401 on sync until a human re-logged in. - persist refresh_token in state (0600, same file) - add refresh grant (grant_type=refresh_token, rotated token saved) - on 401 for authed calls: refresh once, retry the request - persistTokens() keeps the rest of the state intact Build verified in golang:1.23-alpine (vet + gofmt clean). After deploy, one `sm login` issues a refresh token (~30d, rotated on use) and sessions self-heal from then on. --- cli/cmd/smcli/api.go | 40 ++++++++++++++++++++++++++++++++++++++++ cli/cmd/smcli/main.go | 37 +++++++++++++++++++++++++------------ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/cli/cmd/smcli/api.go b/cli/cmd/smcli/api.go index 226d3e2..49569eb 100644 --- a/cli/cmd/smcli/api.go +++ b/cli/cmd/smcli/api.go @@ -23,6 +23,7 @@ type Client struct { Password string AccessToken string + RefreshToken string KDFType int KDFIter uint32 KDFMemory uint32 @@ -72,11 +73,49 @@ func (c *Client) api(method, path string, body any, auth bool) ([]byte, error) { return nil, err } if resp.StatusCode >= 300 { + // access token expired: refresh once and retry (never for the + // identity endpoints themselves, which manage their own tokens) + if resp.StatusCode == 401 && auth && c.RefreshToken != "" && !strings.HasPrefix(path, "/identity/") { + if rerr := c.refresh(); rerr == nil { + return c.api(method, path, body, auth) + } + } return out, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(string(out), 200)) } return out, nil } +// refresh exchanges the persisted refresh_token for a fresh access token +// (Vaultwarden rotates the refresh token on every use). Scope must match +// the original grant (api offline_access). +func (c *Client) refresh() error { + if c.RefreshToken == "" { + return errors.New("no refresh token in state; re-login required") + } + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", c.RefreshToken) + form.Set("client_id", "cli") + form.Set("scope", "api offline_access") + out, err := c.apiRaw("POST", "/identity/connect/token", form, false) + if err != nil { + return fmt.Errorf("refresh: %w", err) + } + var t tokenResp + if err := json.Unmarshal(out, &t); err != nil { + return err + } + if t.AccessToken == "" { + return fmt.Errorf("refresh failed: %s", truncate(string(out), 200)) + } + c.AccessToken = t.AccessToken + if t.RefreshTok != "" { + c.RefreshToken = t.RefreshTok + } + persistTokens(c) + return nil +} + func truncate(s string, n int) string { if len(s) <= n { return s @@ -170,6 +209,7 @@ func (c *Client) Login() error { return fmt.Errorf("login failed: %s", truncate(payload, 300)) } c.AccessToken = t.AccessToken + c.RefreshToken = t.RefreshTok return nil } diff --git a/cli/cmd/smcli/main.go b/cli/cmd/smcli/main.go index c31e87e..5e5bfb7 100644 --- a/cli/cmd/smcli/main.go +++ b/cli/cmd/smcli/main.go @@ -30,14 +30,15 @@ import ( const stateVersion = 1 type State struct { - Version int `json:"version"` - Server string `json:"server"` - Email string `json:"email"` - AccessToken string `json:"access_token"` - KDFType int `json:"kdf_type"` - KDFIter uint32 `json:"kdf_iter"` - KDFMemory uint32 `json:"kdf_memory"` - KDFParallel uint32 `json:"kdf_parallel"` + Version int `json:"version"` + Server string `json:"server"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + KDFType int `json:"kdf_type"` + KDFIter uint32 `json:"kdf_iter"` + KDFMemory uint32 `json:"kdf_memory"` + KDFParallel uint32 `json:"kdf_parallel"` // MasterKey/StretchedKey/UserSymKey stored raw (hex) — file must be 0600. MasterKey string `json:"master_key"` StretchedKey string `json:"stretched_key"` @@ -54,6 +55,18 @@ func stateDir() string { func statePath() string { return filepath.Join(stateDir(), "state.json") } +// persistTokens updates just the token pair in the existing state file +// after a successful refresh (called from api.go refresh()). +func persistTokens(c *Client) { + s, err := loadState() + if err != nil { + return // no readable state; tokens stay in-memory for this run + } + s.AccessToken = c.AccessToken + s.RefreshToken = c.RefreshToken + _ = saveState(s) +} + func saveState(s *State) error { if err := os.MkdirAll(stateDir(), 0o700); err != nil { return err @@ -83,8 +96,8 @@ func loadState() (*State, error) { func newClientFromState(s *State) (*Client, error) { c := &Client{ Server: s.Server, Email: s.Email, - AccessToken: s.AccessToken, - KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel, + AccessToken: s.AccessToken, RefreshToken: s.RefreshToken, + KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel, HTTP: &http.Client{Timeout: 30 * time.Second}, } var err error @@ -108,8 +121,8 @@ func cmdLogin(server, email, password string) error { } s := &State{ Version: stateVersion, Server: server, Email: email, - AccessToken: c.AccessToken, - KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel, + AccessToken: c.AccessToken, RefreshToken: c.RefreshToken, + KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel, MasterKey: toHex(c.MasterKey), StretchedKey: toHex(c.StretchedKey), }