zddc-server now issues its own bearer tokens for non-browser callers (CLI tools, scripts, downstream proxy/cache/mirror instances). No external IDP, no JWKS rotation. Self-service flow: sign in via the browser, visit /.tokens, click "Create token," paste the resulting plaintext into a 0600 file, and pass --bearer-file <path> to whatever calls back into the server. Storage is <ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>, YAML per token with email/created/expires/description. Filename is the *hash* of the plaintext, never the plaintext itself — a leak of the tokens directory exposes hashes, not credentials. Mode 0600 / 0700, atomic writes via temp+rename. Already shielded from public serving by the existing dot-prefix guards in dispatch and fs.ListDirectory. ACLMiddleware now recognises Authorization: Bearer <token>. On valid token, sets the request email from the token file and falls through to the existing ACL chain. On any failure (unknown / expired / store unavailable / Bearer with no validator), returns 401 — no silent fallback to anonymous, so a misconfigured client fails loudly. JSON API at /.api/tokens (GET list, POST create, DELETE /<id> revoke) backs a small inline HTML self-service page at /.tokens. Users can only see and revoke their own tokens; cross-user revoke returns 404 to avoid leaking ownership. --no-auth (ZDDC_NO_AUTH=1) skips ACL enforcement entirely on this instance. On master: anyone reads everything (dev / trusted-LAN / public-read deployments). On a downstream proxy/cache/mirror: trust upstream's filtering, don't re-evaluate ACLs locally. Implemented as a swap to policy.AllowAllDecider; all existing handlers keep calling AllowFromChain unchanged. Distinct from --insecure, which only relaxes the no-root-.zddc startup check. WARN-level startup log when --no-auth is active so accidental enablement is visible. 33 new tests covering token storage, validation/expiry/revocation, the JSON API end-to-end, the HTML page, and the middleware-Bearer integration including the case-insensitive prefix and expired-token paths. Full suite + go vet clean. Doc updates: zddc/README.md "Authentication" rewritten to cover both auth paths and the token UI/API; AGENTS.md gains ZDDC_NO_AUTH and a "Bearer tokens" subsection flagging the dot-prefix-shielding pre- condition; ARCHITECTURE.md adds "Bearer token issuance" and "--no-auth" subsections under "Server security model" with the hash-as-filename rationale and dispatch-shielding regression- sensitivity called out; CLAUDE.md adds a one-line summary of the new auth topology so future agents pick it up by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
349 lines
10 KiB
Go
349 lines
10 KiB
Go
// Package auth provides bearer-token issuance and validation for
|
|
// zddc-server. Tokens are persisted as YAML files under
|
|
// <ZDDC_ROOT>/.zddc.d/tokens/, keyed by the SHA256 hash of the token
|
|
// value (filename = hash, never the plaintext token). The directory is
|
|
// shielded from public serving by the standard dot-prefix guard in the
|
|
// dispatcher and the listing filter in fs.ListDirectory.
|
|
//
|
|
// Format of a token file:
|
|
//
|
|
// email: user@example.com
|
|
// created: 2026-05-08T10:23:00Z
|
|
// expires: 2026-08-08T10:23:00Z # zero/missing = no expiry
|
|
// description: Field laptop # free-form, optional
|
|
//
|
|
// Token validation: client sends `Authorization: Bearer <token>`,
|
|
// server hashes, looks up <dir>/<sha256-hex>. If the file exists, parses
|
|
// cleanly, and is not expired, the token is valid and the request runs
|
|
// as the file's email.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// DirName is the leaf-directory name under <ZDDC_ROOT>/.zddc.d/ where
|
|
// token files live. Kept here so callers don't repeat the literal.
|
|
const DirName = "tokens"
|
|
|
|
// HashLen is the hex-encoded length of a SHA256 hash (the filename
|
|
// length for token files).
|
|
const HashLen = 64
|
|
|
|
// IDLen is the length of the short identifier used in the management UI
|
|
// and revocation API. First IDLen hex chars of the SHA256 hash.
|
|
const IDLen = 8
|
|
|
|
// ErrInvalidToken is returned when the token does not exist, fails to
|
|
// parse, or is expired. Distinct from other errors so callers can map
|
|
// it to a 401 without leaking which case occurred.
|
|
var ErrInvalidToken = errors.New("invalid token")
|
|
|
|
// ErrNotFound is returned by List/Revoke when a requested token does
|
|
// not exist. Revoke also returns this when the token exists but does
|
|
// not belong to the requesting email — same error to avoid leaking
|
|
// existence of other users' tokens.
|
|
var ErrNotFound = errors.New("token not found")
|
|
|
|
// Token holds the metadata persisted alongside a token. The token
|
|
// value itself (the bearer string the client sends) is NOT carried in
|
|
// this struct — it's only known at generation time, returned once to
|
|
// the user, and never re-derivable from the stored data.
|
|
type Token struct {
|
|
// Hash is the hex-encoded SHA256 of the plaintext token. Equal
|
|
// to the filename of the on-disk YAML file.
|
|
Hash string `yaml:"-"`
|
|
|
|
Email string `yaml:"email"`
|
|
Created time.Time `yaml:"created"`
|
|
Expires time.Time `yaml:"expires,omitempty"`
|
|
Description string `yaml:"description,omitempty"`
|
|
}
|
|
|
|
// ID returns the short identifier (first IDLen hex chars of Hash) used
|
|
// in the management UI and the revocation endpoint. Distinct from the
|
|
// secret token so it can be displayed and logged safely.
|
|
func (t Token) ID() string {
|
|
if len(t.Hash) < IDLen {
|
|
return t.Hash
|
|
}
|
|
return t.Hash[:IDLen]
|
|
}
|
|
|
|
// Expired reports whether the token has an expiry in the past. Tokens
|
|
// with a zero Expires never expire.
|
|
func (t Token) Expired(now time.Time) bool {
|
|
return !t.Expires.IsZero() && !now.Before(t.Expires)
|
|
}
|
|
|
|
// Store manages the on-disk token directory.
|
|
type Store struct {
|
|
dir string
|
|
mu sync.Mutex // serializes file creation; reads are lock-free
|
|
}
|
|
|
|
// NewStore opens (and creates if missing) the token directory at
|
|
// <root>/.zddc.d/tokens. Directory mode 0700 — single-user-trust at
|
|
// the FS layer.
|
|
func NewStore(root string) (*Store, error) {
|
|
dir := filepath.Join(root, ".zddc.d", DirName)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create token dir: %w", err)
|
|
}
|
|
// Best-effort tighten if pre-existing dir was looser. Don't fail
|
|
// startup if chmod isn't permitted (NFS/SMB shares often refuse).
|
|
_ = os.Chmod(dir, 0o700)
|
|
return &Store{dir: dir}, nil
|
|
}
|
|
|
|
// Dir returns the absolute path of the token directory. Exposed for
|
|
// tests and operator diagnostics; not used in the request path.
|
|
func (s *Store) Dir() string { return s.dir }
|
|
|
|
// Generate creates a new token for the given email and writes its file.
|
|
// Returns the plaintext bearer token (to be displayed once to the user)
|
|
// and the persisted Token metadata.
|
|
//
|
|
// expires==zero means no expiry. description is free-form; the caller
|
|
// is responsible for any sanitization (e.g. trimming, length-capping).
|
|
func (s *Store) Generate(email, description string, expires time.Time) (string, Token, error) {
|
|
if email == "" {
|
|
return "", Token{}, errors.New("email required")
|
|
}
|
|
|
|
// 32 bytes = 256 bits of entropy, URL-safe base64 → 43-char string.
|
|
var raw [32]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", Token{}, fmt.Errorf("generate token: %w", err)
|
|
}
|
|
plaintext := base64.RawURLEncoding.EncodeToString(raw[:])
|
|
sum := sha256.Sum256([]byte(plaintext))
|
|
hashHex := hex.EncodeToString(sum[:])
|
|
|
|
tok := Token{
|
|
Hash: hashHex,
|
|
Email: email,
|
|
Created: time.Now().UTC().Truncate(time.Second),
|
|
Expires: expires,
|
|
Description: description,
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.writeAtomic(hashHex, tok); err != nil {
|
|
return "", Token{}, err
|
|
}
|
|
return plaintext, tok, nil
|
|
}
|
|
|
|
// Validate looks up a plaintext bearer token. Returns the persisted
|
|
// Token on success, ErrInvalidToken if missing/malformed/expired.
|
|
//
|
|
// Constant-time comparison: not strictly required since the lookup is
|
|
// keyed by the hash (no string comparison happens), but the hex
|
|
// filename comparison via os.Stat is timing-stable enough for our
|
|
// purposes — there's no known oracle that distinguishes "file missing"
|
|
// from "file present but expired" without observing the response code.
|
|
func (s *Store) Validate(plaintext string) (*Token, error) {
|
|
if plaintext == "" {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
sum := sha256.Sum256([]byte(plaintext))
|
|
hashHex := hex.EncodeToString(sum[:])
|
|
|
|
tok, err := s.readByHash(hashHex)
|
|
if err != nil {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
if tok.Expired(time.Now()) {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
// List returns all tokens belonging to the given email, sorted by
|
|
// Created descending (newest first). Returns an empty slice (not nil)
|
|
// when the user has no tokens.
|
|
func (s *Store) List(email string) ([]Token, error) {
|
|
if email == "" {
|
|
return nil, errors.New("email required")
|
|
}
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []Token{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
out := make([]Token, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if len(name) != HashLen || !isHex(name) {
|
|
continue
|
|
}
|
|
tok, err := s.readByHash(name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(tok.Email, email) {
|
|
continue
|
|
}
|
|
out = append(out, *tok)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].Created.After(out[j].Created)
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// Revoke deletes the token identified by the given short ID (first
|
|
// IDLen hex chars of the hash) or the full hash. Only revokes tokens
|
|
// belonging to email; returns ErrNotFound otherwise (whether the token
|
|
// is missing or owned by a different user — to avoid leaking ownership).
|
|
func (s *Store) Revoke(email, idOrHash string) error {
|
|
if email == "" {
|
|
return errors.New("email required")
|
|
}
|
|
idOrHash = strings.ToLower(strings.TrimSpace(idOrHash))
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Full-hash path: O(1) lookup.
|
|
if len(idOrHash) == HashLen && isHex(idOrHash) {
|
|
tok, err := s.readByHash(idOrHash)
|
|
if err != nil {
|
|
return ErrNotFound
|
|
}
|
|
if !strings.EqualFold(tok.Email, email) {
|
|
return ErrNotFound
|
|
}
|
|
return os.Remove(filepath.Join(s.dir, idOrHash))
|
|
}
|
|
|
|
// Short-ID path: must scan. Token IDs are 8 hex chars (~32 bits),
|
|
// collision-resistant in practice for any reasonable per-user
|
|
// token count. We pick the (single) match owned by email.
|
|
if len(idOrHash) < 4 || !isHex(idOrHash) {
|
|
return ErrNotFound
|
|
}
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return ErrNotFound
|
|
}
|
|
var matches []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if len(name) != HashLen || !isHex(name) {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(name, idOrHash) {
|
|
continue
|
|
}
|
|
tok, err := s.readByHash(name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !strings.EqualFold(tok.Email, email) {
|
|
continue
|
|
}
|
|
matches = append(matches, name)
|
|
}
|
|
if len(matches) == 0 {
|
|
return ErrNotFound
|
|
}
|
|
if len(matches) > 1 {
|
|
return fmt.Errorf("ambiguous id %q matches %d tokens", idOrHash, len(matches))
|
|
}
|
|
return os.Remove(filepath.Join(s.dir, matches[0]))
|
|
}
|
|
|
|
// readByHash reads and parses the token file named by hashHex. The
|
|
// returned Token has its Hash field set.
|
|
func (s *Store) readByHash(hashHex string) (*Token, error) {
|
|
if len(hashHex) != HashLen || !isHex(hashHex) {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
path := filepath.Join(s.dir, hashHex)
|
|
bytes, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tok Token
|
|
if err := yaml.Unmarshal(bytes, &tok); err != nil {
|
|
return nil, err
|
|
}
|
|
tok.Hash = hashHex
|
|
if tok.Email == "" {
|
|
return nil, ErrInvalidToken
|
|
}
|
|
return &tok, nil
|
|
}
|
|
|
|
// writeAtomic serializes a token to YAML and writes it via tmp+rename.
|
|
// File mode 0600.
|
|
func (s *Store) writeAtomic(hashHex string, tok Token) error {
|
|
bytes, err := yaml.Marshal(tok)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal token: %w", err)
|
|
}
|
|
final := filepath.Join(s.dir, hashHex)
|
|
tmp, err := os.CreateTemp(s.dir, ".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write(bytes); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpName)
|
|
return fmt.Errorf("write temp: %w", err)
|
|
}
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpName)
|
|
return fmt.Errorf("chmod temp: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpName)
|
|
return fmt.Errorf("close temp: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, final); err != nil {
|
|
os.Remove(tmpName)
|
|
return fmt.Errorf("rename temp: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isHex reports whether s is a non-empty string of [0-9a-f] characters.
|
|
func isHex(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|