Merge pull request 'smcli self-healing sessions (refresh grant + auto-relogin)' (#4) from ic-builder/self-heal into main
ci / vet (push) Successful in 59s
ci / diagrams (push) Failing after 2m1s

This commit was merged in pull request #4.
This commit is contained in:
2026-09-07 14:43:57 +00:00
4 changed files with 104 additions and 19 deletions
+71 -1
View File
@@ -21,8 +21,11 @@ type Client struct {
HTTP *http.Client HTTP *http.Client
Email string Email string
Password string Password string
TOTPSecret string
reloginDone bool
AccessToken string AccessToken string
RefreshToken string
KDFType int KDFType int
KDFIter uint32 KDFIter uint32
KDFMemory uint32 KDFMemory uint32
@@ -72,11 +75,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
@@ -141,7 +182,7 @@ func (c *Client) Login() error {
if t.AccessToken == "" { if t.AccessToken == "" {
// 2FA retry path (provider 0 = authenticator TOTP) // 2FA retry path (provider 0 = authenticator TOTP)
if strings.Contains(string(out), "Two factor required") { if strings.Contains(string(out), "Two factor required") {
secret := os.Getenv("SM_TOTP_SECRET") secret := c.TOTPSecret
if secret != "" { if secret != "" {
code, terr := totpNow(secret, time.Now()) code, terr := totpNow(secret, time.Now())
if terr != nil { if terr != nil {
@@ -170,6 +211,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
} }
@@ -287,3 +329,31 @@ func (c *Client) DeleteCipher(id string) error {
_, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true) _, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true)
return nil return nil
} }
// selfRelogin performs the full login+unlock using SM_* env credentials
// (injected by the sm shims from the TSGCOO vault-account env). Saves state.
func (c *Client) selfRelogin() error {
if c.Password == "" {
c.Password = os.Getenv("SM_PASSWORD")
}
if c.TOTPSecret == "" {
c.TOTPSecret = os.Getenv("SM_TOTP_SECRET")
}
if c.Password == "" {
return errors.New("relogin unavailable: SM_PASSWORD not set")
}
if err := c.Login(); err != nil {
return err
}
if err := c.Unlock(); err != nil {
return err
}
if s, err := loadState(); err == nil {
s.AccessToken = c.AccessToken
s.UserSymKey = toHex(c.UserSymKey)
s.StretchedKey = toHex(c.StretchedKey)
s.MasterKey = toHex(c.MasterKey)
_ = saveState(s)
}
return nil
}
+17 -2
View File
@@ -34,6 +34,7 @@ type State struct {
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"`
RefreshToken string `json:"refresh_token,omitempty"`
KDFType int `json:"kdf_type"` KDFType int `json:"kdf_type"`
KDFIter uint32 `json:"kdf_iter"` KDFIter uint32 `json:"kdf_iter"`
KDFMemory uint32 `json:"kdf_memory"` KDFMemory uint32 `json:"kdf_memory"`
@@ -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,7 +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, Password: os.Getenv("SM_PASSWORD"), TOTPSecret: os.Getenv("SM_TOTP_SECRET"),
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},
} }
@@ -101,7 +115,7 @@ func newClientFromState(s *State) (*Client, error) {
} }
func cmdLogin(server, email, password string) error { func cmdLogin(server, email, password string) error {
c := &Client{Server: server, Email: email, Password: password, c := &Client{Server: server, Email: email, Password: password, TOTPSecret: os.Getenv("SM_TOTP_SECRET"),
HTTP: &http.Client{Timeout: 30 * time.Second}} HTTP: &http.Client{Timeout: 30 * time.Second}}
if err := c.Login(); err != nil { if err := c.Login(); err != nil {
return err return err
@@ -109,6 +123,7 @@ 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),
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -10,7 +10,7 @@
name: knel-secretsmanager name: knel-secretsmanager
services: services:
smcli: smcli:
image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:46d80c0a0ef53a9303dd54b3799a64a61cd4e4dc892282bc4d8a220378117ce9 image: git.knownelement.com/knel/knel-secretsmanager-cli@sha256:8abfc55dfa7ca9e70b286a249b7ca823531bd55da29bed70d3354a58ec4d8fec
container_name: ukrrs-secretsmgr-cli container_name: ukrrs-secretsmgr-cli
restart: unless-stopped restart: unless-stopped
entrypoint: ["sleep", "infinity"] entrypoint: ["sleep", "infinity"]