// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package tokens stores personal access tokens for programmatic writes // to the public API. Tokens live in tokens.toml next to users.toml; the // raw token is shown exactly once, at creation time, and only its // SHA-256 digest is stored. package tokens import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "log/slog" "os" "slices" "strings" "sync" "time" "sourcedock.dev/petrbalvin/interpres/v2" "sourcedock.dev/petrbalvin/volumen/internal/tomlfile" ) // TokenPrefix marks volumen API tokens. const TokenPrefix = "vol_" // ValidScopes are the recognised API token scopes. There is no read // scope: every read endpoint is public, so a token can only widen // access to the write and delete operations. var ValidScopes = []string{"write", "delete"} // ErrNoValidScope is returned when a token was requested with an // explicit scope list that names no recognised scope. Treating it as // "unrestricted" would hand out more access than the caller asked for. var ErrNoValidScope = errors.New("no valid scope in the requested list") // Token is one stored access token (digest only, never the raw value). type Token struct { Name string TokenHash string Created string LastUsed string Scopes []string // nil means unrestricted } // HasScope reports whether the token grants scope. func (t *Token) HasScope(scope string) bool { if t.Scopes == nil { return true } return slices.Contains(t.Scopes, scope) } // HashToken returns the SHA-256 hex digest of a raw token. func HashToken(raw string) string { sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) } // GenerateToken mints a new raw token with the volumen prefix. // crypto/rand.Text returns 128 bits of randomness in a URL-safe alphabet, // and panics on a system failure rather than returning a weak value. func GenerateToken() string { return TokenPrefix + rand.Text() } // Store is the file-backed store of API access tokens, with an mtime // snapshot cache so that authentication does not re-read the file on // every request. type Store struct { path string mu sync.Mutex cached []Token snapshot fileSnapshot haveCache bool lock sync.Mutex } type fileSnapshot struct { present bool mtime int64 size int64 } // New opens the token store at path. func New(path string) *Store { return &Store{path: path} } // All returns every stored token record. func (s *Store) All() []Token { s.mu.Lock() defer s.mu.Unlock() tokens, err := s.loadLocked() if err != nil { slog.Error("tokens: cannot read the token file", "path", s.path, "error", err) return nil } return slices.Clone(tokens) } // loadLocked returns the cached records, rebuilding them when the file // changed. A missing file is not an error; an unreadable or unparsable // one is. The caller must hold s.mu. func (s *Store) loadLocked() ([]Token, error) { snapshot := s.buildSnapshot() if s.haveCache && snapshot == s.snapshot { return s.cached, nil } tokens, err := s.readFile() if err != nil { return nil, err } s.snapshot = snapshot s.cached = tokens s.haveCache = true return tokens, nil } func (s *Store) buildSnapshot() fileSnapshot { info, err := os.Stat(s.path) if err != nil { return fileSnapshot{} } return fileSnapshot{present: true, mtime: info.ModTime().UnixNano(), size: info.Size()} } func (s *Store) readFile() ([]Token, error) { raw, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, fmt.Errorf("read %s: %w", s.path, err) } data, err := interpres.ParseMap(raw) if err != nil { return nil, fmt.Errorf("parse %s: %w", s.path, err) } var out []Token for _, entry := range tomlfile.Tables(data["tokens"]) { name := tomlfile.String(entry["name"]) hash := tomlfile.String(entry["token_hash"]) if name == "" || hash == "" { continue } token := Token{ Name: name, TokenHash: hash, Created: tomlfile.String(entry["created"]), LastUsed: tomlfile.String(entry["last_used"]), } if scopes := tomlfile.Strings(entry["scopes"]); len(scopes) > 0 { token.Scopes = scopes } out = append(out, token) } return out, nil } // Create mints a token. It returns nil and an empty raw value when the // name is taken or empty, when the scope list names no recognised // scope, or when the file cannot be written. An empty scope list creates // an unrestricted token, which only a caller that asks for one gets. func (s *Store) Create(name string, scopes []string) (*Token, string, error) { name = strings.TrimSpace(name) if name == "" { return nil, "", errors.New("token name must not be empty") } if len(scopes) > 0 { valid := make([]string, 0, len(scopes)) for _, scope := range scopes { if slices.Contains(ValidScopes, scope) && !slices.Contains(valid, scope) { valid = append(valid, scope) } } if len(valid) == 0 { return nil, "", ErrNoValidScope } scopes = valid } s.lock.Lock() defer s.lock.Unlock() existing, err := s.reload() if err != nil { return nil, "", err } for _, token := range existing { if token.Name == name { return nil, "", fmt.Errorf("a token named %q already exists", name) } } raw := GenerateToken() record := Token{ Name: name, TokenHash: HashToken(raw), Created: nowISO(), Scopes: scopes, } if err := s.persist(append(existing, record)); err != nil { return nil, "", err } return &record, raw, nil } // Authenticate returns the matching record for a presented raw token. func (s *Store) Authenticate(raw string) *Token { if !strings.HasPrefix(raw, TokenPrefix) { return nil } digest := HashToken(raw) all := s.All() for i := range all { if hmac.Equal([]byte(all[i].TokenHash), []byte(digest)) { return &all[i] } } return nil } // Touch refreshes last_used, at most once per UTC day per token. func (s *Store) Touch(name string) { today := nowISO()[:10] // Fast path under the cache lock: when the cached records already // carry today for this token, no write and no reload are needed, so // authentication does not serialise on the file. s.mu.Lock() if cached, err := s.loadLocked(); err == nil { for i := range cached { if cached[i].Name == name && strings.HasPrefix(cached[i].LastUsed, today) { s.mu.Unlock() return } } } s.mu.Unlock() s.lock.Lock() defer s.lock.Unlock() tokens, err := s.reload() if err != nil { slog.Warn("tokens: cannot refresh last_used", "path", s.path, "error", err) return } changed := false for i := range tokens { if tokens[i].Name == name && !strings.HasPrefix(tokens[i].LastUsed, today) { tokens[i].LastUsed = nowISO() changed = true } } if changed { if err := s.persist(tokens); err != nil { slog.Warn("tokens: cannot persist last_used", "path", s.path, "error", err) } } } // Revoke removes a token by name; false when nothing was removed or the // file cannot be written. func (s *Store) Revoke(name string) bool { s.lock.Lock() defer s.lock.Unlock() tokens, err := s.reload() if err != nil { slog.Error("tokens: refusing to revoke, the token file is unreadable", "path", s.path, "error", err) return false } remaining := make([]Token, 0, len(tokens)) for _, token := range tokens { if token.Name != name { remaining = append(remaining, token) } } if len(remaining) == len(tokens) { return false } return s.persist(remaining) == nil } // reload re-reads the file, refusing to carry on when it cannot be // parsed: writing back a list derived from an unreadable file would // destroy the tokens it contains. The caller must hold s.lock. func (s *Store) reload() ([]Token, error) { s.Invalidate() s.mu.Lock() defer s.mu.Unlock() return s.loadLocked() } // Health reports why the token file cannot be read, or nil when it is // fine or absent. func (s *Store) Health() error { s.mu.Lock() defer s.mu.Unlock() _, err := s.loadLocked() return err } // Invalidate drops the cached records so the next read re-reads the // file. func (s *Store) Invalidate() { s.mu.Lock() defer s.mu.Unlock() s.cached = nil s.snapshot = fileSnapshot{} s.haveCache = false } func (s *Store) persist(tokens []Token) error { entries := make([]map[string]any, 0, len(tokens)) for _, token := range tokens { entry := map[string]any{ "name": token.Name, "token_hash": token.TokenHash, "created": token.Created, } if token.LastUsed != "" { entry["last_used"] = token.LastUsed } if len(token.Scopes) > 0 { scopes := make([]string, len(token.Scopes)) for i, scope := range token.Scopes { scopes[i] = scope } entry["scopes"] = scopes } entries = append(entries, entry) } if err := tomlfile.Write(s.path, "tokens", entries); err != nil { slog.Error("tokens: cannot persist", "path", s.path, "error", err) return err } s.Invalidate() return nil } func nowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05-07:00") }