Files
2026-08-15 07:13:00 +00:00

248 lines
7.0 KiB
Go

package auth
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/iceBear67/simplepages/internal/store"
)
// Identity is what a verified token proves. It is immutable once returned and
// is shared by every request using that key, so callers must not modify it.
type Identity struct {
KeyID string
Scope store.Scope
ProjectID *int64 // nil for admin keys
Name string
ExpiresAt *time.Time
}
// IsAdmin reports whether the identity may act on every project.
func (i *Identity) IsAdmin() bool { return i != nil && i.Scope == store.ScopeAdmin }
// Owns reports whether the identity may act on the project with this row id.
// Admins own everything.
//
// Callers must pass a resolved row id, never a name from the URL: comparing
// names would make the boundary depend on string handling in every handler.
func (i *Identity) Owns(projectID int64) bool {
if i == nil {
return false
}
if i.Scope == store.ScopeAdmin {
return true
}
return i.ProjectID != nil && *i.ProjectID == projectID
}
// Failure reasons. All of them are reported to the client as one indistinct
// 401: telling an unauthenticated caller whether a key exists, is revoked or
// merely expired is free reconnaissance.
var (
ErrUnknownKey = errors.New("auth: unknown key id")
ErrBadSecret = errors.New("auth: secret mismatch")
ErrRevoked = errors.New("auth: key revoked")
ErrExpired = errors.New("auth: key expired")
)
// DefaultCacheTTL bounds how long a revocation can take to become visible if
// the process that revoked it is not this one. Within one process, Invalidate
// makes revocation immediate.
const DefaultCacheTTL = 60 * time.Second
// Verifier turns a bearer token into an Identity.
//
// Verified keys are cached, because otherwise every deploy request would pay a
// database round trip before doing any work. The cache stores only positive
// results: caching unknown key ids would let anyone grow the map without bound
// by presenting random tokens. An unknown id costs one indexed lookup on a
// WITHOUT ROWID table, and the rate limiter covers the flood case.
//
// sync.Map fits this exactly — the key set is small and stable, entries are
// written once and read many times, and different goroutines mostly touch
// different keys.
type Verifier struct {
db *store.DB
log *slog.Logger
ttl time.Duration
cache sync.Map // keyID -> *cacheEntry
gen atomic.Uint64
// Pending last-use timestamps, flushed in batches. Writing last_used_at per
// request would funnel every authenticated read through the single write
// connection, which is the contention the two-pool design exists to avoid.
mu sync.Mutex
touch map[string]time.Time
now func() time.Time // swapped in tests
}
type cacheEntry struct {
ident *Identity
hash []byte
gen uint64
exp time.Time
}
// NewVerifier returns a verifier reading from db. A ttl of zero means
// DefaultCacheTTL.
func NewVerifier(db *store.DB, log *slog.Logger, ttl time.Duration) *Verifier {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Verifier{
db: db,
log: log,
ttl: ttl,
touch: make(map[string]time.Time),
now: time.Now,
}
}
// Verify authenticates a bearer token.
//
// On success it also records the key as used; the timestamp is written to the
// database later, in a batch, so it is approximate by design.
func (v *Verifier) Verify(ctx context.Context, token string) (*Identity, error) {
keyID, secret, err := Parse(token)
if err != nil {
return nil, err
}
now := v.now()
gen := v.gen.Load()
entry, ok := v.lookupCache(keyID, gen, now)
if !ok {
key, err := v.db.KeyByID(ctx, keyID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnknownKey
}
return nil, err
}
entry = &cacheEntry{
ident: identityOf(key),
hash: key.SecretHash,
gen: gen,
exp: now.Add(v.ttl),
}
// Revoked keys are never cached: the entry would only ever produce a
// rejection, and keeping it lets a caller pin memory with a dead key.
if key.RevokedAt != nil {
return nil, ErrRevoked
}
v.cache.Store(keyID, entry)
}
// The comparison happens on every request, cache hit or not. The cache
// saves the database round trip; it must never save the check itself.
if !SecretMatches(secret, entry.hash) {
return nil, ErrBadSecret
}
if entry.ident.ExpiresAt != nil && !now.Before(*entry.ident.ExpiresAt) {
return nil, ErrExpired
}
v.recordUse(keyID, now)
return entry.ident, nil
}
func (v *Verifier) lookupCache(keyID string, gen uint64, now time.Time) (*cacheEntry, bool) {
raw, ok := v.cache.Load(keyID)
if !ok {
return nil, false
}
e := raw.(*cacheEntry)
if e.gen != gen || !now.Before(e.exp) {
v.cache.Delete(keyID)
return nil, false
}
return e, true
}
// Invalidate discards every cached identity.
//
// Called after any key or project change. Bumping a generation counter rather
// than deleting individual entries is deliberate: a caller that forgets which
// ids a change touched cannot leave a stale entry behind, and the cost is one
// atomic load per verification.
func (v *Verifier) Invalidate() { v.gen.Add(1) }
func (v *Verifier) recordUse(keyID string, at time.Time) {
v.mu.Lock()
defer v.mu.Unlock()
if prev, ok := v.touch[keyID]; !ok || at.After(prev) {
v.touch[keyID] = at
}
}
// FlushTouches writes the accumulated last-use timestamps.
//
// The pending set is taken before the write and not restored on failure: a lost
// last_used_at is a cosmetic loss, and retrying would let a persistently
// failing write grow the map without bound.
func (v *Verifier) FlushTouches(ctx context.Context) error {
v.mu.Lock()
pending := v.touch
v.touch = make(map[string]time.Time)
v.mu.Unlock()
if len(pending) == 0 {
return nil
}
return v.db.TouchKeys(ctx, pending)
}
// RunFlusher writes pending last-use timestamps every interval until ctx is
// done, then flushes once more so a clean shutdown does not drop them.
func (v *Verifier) RunFlusher(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Minute
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
// ctx is already cancelled, so the final flush needs its own
// deadline or TouchKeys would return immediately.
final, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := v.FlushTouches(final); err != nil && v.log != nil {
v.log.Warn("final last_used_at flush failed", "error", err)
}
return
case <-t.C:
if err := v.FlushTouches(ctx); err != nil && v.log != nil {
v.log.Warn("last_used_at flush failed", "error", err)
}
}
}
}
func identityOf(k *store.APIKey) *Identity {
// Everything reachable from a cached Identity is copied: the value is shared
// by every concurrent request using that key, so it must not alias a struct
// the store still owns.
id := &Identity{
KeyID: k.ID,
Scope: k.Scope,
Name: k.Name,
}
if k.ProjectID != nil {
pid := *k.ProjectID
id.ProjectID = &pid
}
if k.ExpiresAt != nil {
exp := *k.ExpiresAt
id.ExpiresAt = &exp
}
return id
}