Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+347
View File
@@ -0,0 +1,347 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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")
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package tokens
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCreateAndAuthenticate(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.toml")
s := New(path)
record, raw, err := s.Create("ci", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
if record == nil || raw == "" {
t.Fatal("Create failed")
}
if !strings.HasPrefix(raw, TokenPrefix) {
t.Fatalf("raw = %q", raw)
}
if record.TokenHash == raw {
t.Fatal("raw token persisted")
}
found := s.Authenticate(raw)
if found == nil || found.Name != "ci" {
t.Fatalf("Authenticate = %v", found)
}
if !found.HasScope("write") {
t.Fatal("unrestricted token should grant every scope")
}
if s.Authenticate("vol_wrong") != nil {
t.Fatal("wrong token authenticated")
}
if s.Authenticate("not-our-prefix") != nil {
t.Fatal("foreign prefix authenticated")
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v", info.Mode().Perm())
}
rawFile, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if strings.Contains(string(rawFile), raw) {
t.Fatal("raw token leaked into the file")
}
}
func TestCreateScopes(t *testing.T) {
s := New(filepath.Join(t.TempDir(), "tokens.toml"))
record, _, err := s.Create("scoped", []string{"write", "bogus", "delete"})
if err != nil {
t.Fatalf("Create: %v", err)
}
if record == nil {
t.Fatal("Create failed")
}
if len(record.Scopes) != 2 || record.Scopes[0] != "write" || record.Scopes[1] != "delete" {
t.Fatalf("scopes = %v", record.Scopes)
}
if record.HasScope("read") {
t.Fatal("read scope granted")
}
if !record.HasScope("write") {
t.Fatal("write scope missing")
}
// An explicit list that names no valid scope must be refused, not
// turned into an unrestricted token.
if _, _, err := s.Create("invalid-only", []string{"bogus"}); !errors.Is(err, ErrNoValidScope) {
t.Fatalf("err = %v, want ErrNoValidScope", err)
}
}
func TestCreateRejectsDuplicatesAndEmpty(t *testing.T) {
s := New(filepath.Join(t.TempDir(), "tokens.toml"))
if record, _, err := s.Create("dup", nil); err != nil || record == nil {
t.Fatalf("first Create: %v, %v", record, err)
}
if record, _, err := s.Create("dup", nil); err == nil || record != nil {
t.Fatal("duplicate name accepted")
}
if record, _, err := s.Create(" ", nil); err == nil || record != nil {
t.Fatal("empty name accepted")
}
}
func TestPersistedAcrossReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.toml")
s := New(path)
_, raw, err := s.Create("ci", []string{"write"})
if err != nil {
t.Fatalf("Create: %v", err)
}
reopened := New(path)
if len(reopened.All()) != 1 {
t.Fatalf("tokens = %v", reopened.All())
}
if reopened.Authenticate(raw) == nil {
t.Fatal("token lost across reopen")
}
}
func TestTouchRefreshesOncePerDay(t *testing.T) {
s := New(filepath.Join(t.TempDir(), "tokens.toml"))
record, _, err := s.Create("ci", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
s.Touch("ci")
after := s.All()[0]
if after.LastUsed == "" {
t.Fatal("last_used not set")
}
s.Touch("ci")
again := s.All()[0]
if again.LastUsed != after.LastUsed {
t.Fatal("last_used updated twice in one day")
}
s.Touch("missing")
if record.Created == "" {
t.Fatal("created missing")
}
}
func TestRevoke(t *testing.T) {
s := New(filepath.Join(t.TempDir(), "tokens.toml"))
s.Create("one", nil)
if !s.Revoke("one") {
t.Fatal("Revoke failed")
}
if s.Revoke("one") {
t.Fatal("Revoke succeeded twice")
}
if len(s.All()) != 0 {
t.Fatalf("tokens = %v", s.All())
}
}
func TestUnreadableFileYieldsNoTokens(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.toml")
if err := os.WriteFile(path, []byte("broken = = ="), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if got := New(path).All(); got != nil {
t.Fatalf("tokens = %v, want none", got)
}
}
func TestMissingFileYieldsNoTokens(t *testing.T) {
if got := New(filepath.Join(t.TempDir(), "nope.toml")).All(); got != nil {
t.Fatalf("tokens = %v, want none", got)
}
}