ZDDC/zddc/internal/auth/token.go
ZDDC 97ffaac13b feat(server): self-issued bearer tokens + --no-auth flag
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>
2026-05-08 07:40:28 -05:00

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
}