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.
This commit is contained in:
@@ -23,6 +23,7 @@ type Client struct {
|
|||||||
Password string
|
Password string
|
||||||
|
|
||||||
AccessToken string
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
KDFType int
|
KDFType int
|
||||||
KDFIter uint32
|
KDFIter uint32
|
||||||
KDFMemory uint32
|
KDFMemory uint32
|
||||||
@@ -72,11 +73,49 @@ func (c *Client) api(method, path string, body any, auth bool) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.StatusCode >= 300 {
|
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, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(string(out), 200))
|
||||||
}
|
}
|
||||||
return out, nil
|
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 {
|
func truncate(s string, n int) string {
|
||||||
if len(s) <= n {
|
if len(s) <= n {
|
||||||
return s
|
return s
|
||||||
@@ -170,6 +209,7 @@ func (c *Client) Login() error {
|
|||||||
return fmt.Errorf("login failed: %s", truncate(payload, 300))
|
return fmt.Errorf("login failed: %s", truncate(payload, 300))
|
||||||
}
|
}
|
||||||
c.AccessToken = t.AccessToken
|
c.AccessToken = t.AccessToken
|
||||||
|
c.RefreshToken = t.RefreshTok
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-12
@@ -30,14 +30,15 @@ import (
|
|||||||
const stateVersion = 1
|
const stateVersion = 1
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Server string `json:"server"`
|
Server string `json:"server"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
KDFType int `json:"kdf_type"`
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
KDFIter uint32 `json:"kdf_iter"`
|
KDFType int `json:"kdf_type"`
|
||||||
KDFMemory uint32 `json:"kdf_memory"`
|
KDFIter uint32 `json:"kdf_iter"`
|
||||||
KDFParallel uint32 `json:"kdf_parallel"`
|
KDFMemory uint32 `json:"kdf_memory"`
|
||||||
|
KDFParallel uint32 `json:"kdf_parallel"`
|
||||||
// MasterKey/StretchedKey/UserSymKey stored raw (hex) — file must be 0600.
|
// MasterKey/StretchedKey/UserSymKey stored raw (hex) — file must be 0600.
|
||||||
MasterKey string `json:"master_key"`
|
MasterKey string `json:"master_key"`
|
||||||
StretchedKey string `json:"stretched_key"`
|
StretchedKey string `json:"stretched_key"`
|
||||||
@@ -54,6 +55,18 @@ func stateDir() string {
|
|||||||
|
|
||||||
func statePath() string { return filepath.Join(stateDir(), "state.json") }
|
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 {
|
func saveState(s *State) error {
|
||||||
if err := os.MkdirAll(stateDir(), 0o700); err != nil {
|
if err := os.MkdirAll(stateDir(), 0o700); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -83,8 +96,8 @@ func loadState() (*State, error) {
|
|||||||
func newClientFromState(s *State) (*Client, error) {
|
func newClientFromState(s *State) (*Client, error) {
|
||||||
c := &Client{
|
c := &Client{
|
||||||
Server: s.Server, Email: s.Email,
|
Server: s.Server, Email: s.Email,
|
||||||
AccessToken: s.AccessToken,
|
AccessToken: s.AccessToken, RefreshToken: s.RefreshToken,
|
||||||
KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel,
|
KDFType: s.KDFType, KDFIter: s.KDFIter, KDFMemory: s.KDFMemory, KDFParallel: s.KDFParallel,
|
||||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
@@ -108,8 +121,8 @@ func cmdLogin(server, email, password string) error {
|
|||||||
}
|
}
|
||||||
s := &State{
|
s := &State{
|
||||||
Version: stateVersion, Server: server, Email: email,
|
Version: stateVersion, Server: server, Email: email,
|
||||||
AccessToken: c.AccessToken,
|
AccessToken: c.AccessToken, RefreshToken: c.RefreshToken,
|
||||||
KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel,
|
KDFType: c.KDFType, KDFIter: c.KDFIter, KDFMemory: c.KDFMemory, KDFParallel: c.KDFParallel,
|
||||||
MasterKey: toHex(c.MasterKey),
|
MasterKey: toHex(c.MasterKey),
|
||||||
StretchedKey: toHex(c.StretchedKey),
|
StretchedKey: toHex(c.StretchedKey),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user