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 }