package shared import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) // Token is the cached result of a successful login. It stores enough detail to // later inspect or revoke the token (accessor, policies) and to reason about // its lifetime (issued/expiry, renewable) — not just the secret itself. type Token struct { // Context is the name of the context this token belongs to. Context string `json:"context"` // Address is the Vault address the token is valid against. Address string `json:"address"` // Namespace is the Vault namespace the token was issued in (may be empty). Namespace string `json:"namespace,omitempty"` // Token is the client token secret. Token string `json:"token"` // Accessor identifies the token without exposing it — enough to revoke it // via /auth/token/revoke-accessor. Accessor string `json:"accessor,omitempty"` // Policies are the policies attached to the token. Policies []string `json:"policies,omitempty"` // Renewable reports whether the token can be renewed. Renewable bool `json:"renewable"` // LeaseDurationSeconds is the TTL granted at issue/renew time. LeaseDurationSeconds int `json:"lease_duration_seconds,omitempty"` // IssuedAt is when this token was obtained (login) or last renewed. IssuedAt time.Time `json:"issued_at"` // ExpiresAt is IssuedAt + LeaseDuration; zero for non-expiring tokens. ExpiresAt time.Time `json:"expires_at,omitempty"` } // CacheDir returns the XDG_CACHE_HOME/vault directory that holds token files. func CacheDir() string { base := os.Getenv("XDG_CACHE_HOME") if base == "" { home, _ := os.UserHomeDir() base = filepath.Join(home, ".cache") } return filepath.Join(base, appDir) } // TokenPath returns the on-disk path for a context's cached token. A context // name with slashes (e.g. "staging/sydney") maps to a nested path under // CacheDir. The name is validated to prevent escaping the cache directory. func TokenPath(context string) (string, error) { if err := validateContextName(context); err != nil { return "", err } // Use forward slashes as path separators regardless of platform, matching // how contexts are written in the config file. rel := filepath.FromSlash(context) return filepath.Join(CacheDir(), rel), nil } // validateContextName rejects names that could escape the cache directory or // are otherwise unusable as a relative path. func validateContextName(context string) error { if context == "" { return fmt.Errorf("empty context name") } if strings.HasPrefix(context, "/") || filepath.IsAbs(context) { return fmt.Errorf("context name %q must not be absolute", context) } for _, seg := range strings.Split(context, "/") { if seg == "" { return fmt.Errorf("context name %q has an empty path segment", context) } if seg == "." || seg == ".." { return fmt.Errorf("context name %q must not contain %q segments", context, seg) } } return nil } // SaveToken writes a token to its cache path as JSON, creating parent // directories (0700) as needed and writing the file with 0600 permissions. func SaveToken(t *Token) error { path, err := TokenPath(t.Context) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating cache dir: %w", err) } data, err := json.MarshalIndent(t, "", " ") if err != nil { return fmt.Errorf("encoding token: %w", err) } data = append(data, '\n') // Write via a temp file + rename so a token file is never left partially // written, and create it 0600 from the start (never briefly world-readable). tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("writing token: %w", err) } if err := os.Rename(tmp, path); err != nil { _ = os.Remove(tmp) return fmt.Errorf("saving token: %w", err) } return nil } // LoadToken reads and decodes a cached token for a context. A missing token // returns an error wrapping os.ErrNotExist so callers can detect "not logged // in" with errors.Is. func LoadToken(context string) (*Token, error) { path, err := TokenPath(context) if err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("no cached token for context %q (run 'vctl login %s'): %w", context, context, os.ErrNotExist) } return nil, fmt.Errorf("reading token %s: %w", path, err) } var t Token if err := json.Unmarshal(data, &t); err != nil { return nil, fmt.Errorf("parsing token %s: %w", path, err) } return &t, nil } // DeleteToken removes a context's cached token file, if present. func DeleteToken(context string) error { path, err := TokenPath(context) if err != nil { return err } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing token %s: %w", path, err) } return nil }