@@ 0,0 1,504 @@
+// Package bearer validates a tokens.sr.ht working token, and is the one copy of
+// that check for every service on the instance (SPEC ch. 6).
+//
+// A working token is an auth.BearerToken: the version, expiry, grants, client id
+// and owner in BARE, HMAC-SHA256 over the lot, base64. It is the same format and
+// the same key meta.sr.ht stamps its own personal access tokens with, so a
+// service needs no new cryptographic stack to accept one — and, for the same
+// reason, the signature alone does not say who issued the token. Only the client
+// id does, which is why step 2 below exists at all.
+//
+// The four steps, in order, are SPEC ch. 6:
+//
+// 1. decode and verify (local, no network);
+// 2. is this token ours? — if not, the *service's* policy decides;
+// 3. does it grant the action being attempted?
+// 4. if it is a registered token, is it still live? — the one step that talks
+// to tokens.sr.ht, cached, and skipped entirely by a short token.
+//
+// The order is not an implementation detail. Every step that can refuse locally
+// runs before the one that cannot, so the network is touched only for a token
+// that has already proved it is well-formed, ours, unexpired and sufficient for
+// what its holder is doing. A forged or expired credential never reaches the
+// daemon, and an instance under a flood of junk tokens does not turn that flood
+// into traffic against tokens.sr.ht.
+//
+// Usage:
+//
+// v, err := bearer.New(bearer.Options{
+// Origin: conf.Get("tokens.sr.ht", "origin"),
+// ClientID: "bench.sr.ht",
+// NodeID: hostname,
+// })
+// ...
+// tok, err := v.Validate(r.Context(), presented, "bench:upload")
+//
+// The process must have run crypto.InitCrypto before any of this: the signing
+// key of step 1 and the network key that seals the internal authorization of
+// step 4 both live in that package's globals. This is the same precondition
+// every core-go authentication path carries and it is not checked here, because
+// there is nothing this package could usefully do about it at request time.
+//
+// Resolving the owner is not this package's job. The token names a meta.sr.ht
+// username; turning that into a local row is auth.LookupUser plus whatever the
+// service's own plan does with a user it has never seen (SPEC ch. 6), and that
+// differs per service in ways a shared validator should not have opinions about.
+package bearer
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/auth"
+ "sourcecraft.dev/bigbes/sr-ht-core/crypto"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/grants"
+)
+
+// TokensClientID is the ClientID tokens.sr.ht stamps into every working token it
+// seals, and the only thing that distinguishes one from a meta.sr.ht PAT: the
+// two share a signing key, so a valid signature says the instance made the
+// token and not which part of it did (SPEC ch. 1).
+const TokensClientID = "tokens.sr.ht"
+
+// DefaultCacheTTL is how long a revocation answer is reused when Options leaves
+// CacheTTL at zero. Sixty seconds, the figure SPEC ch. 6 step 4 names.
+const DefaultCacheTTL = 60 * time.Second
+
+// defaultHTTPTimeout bounds a revocation check when the caller supplies no
+// client of its own.
+//
+// It has to be short. This request sits on the hot path of an upload, behind a
+// context the caller may not have given a deadline to, and the endpoint it calls
+// answers from one indexed row — a healthy daemon replies in single-digit
+// milliseconds. Five seconds is generous for that and still short enough that a
+// hung tokens.sr.ht turns into 503s rather than into request-handler goroutines
+// piling up across every service on the instance.
+const defaultHTTPTimeout = 5 * time.Second
+
+// revocationPath is the endpoint of SPEC ch. 5, joined to Origin.
+const revocationPath = "/api/v1/revocations/"
+
+// maxCacheEntries bounds the revocation cache. See (*Validator).remember for
+// what happens at the bound and why that is the right thing to happen.
+const maxCacheEntries = 4096
+
+// The refusals of SPEC ch. 6, and the status each one is for a service.
+//
+// They are separate sentinels rather than one error with a code because the
+// mapping is not uniform, and the interesting cases are the two that are not
+// 401:
+//
+// - ErrInvalid — 401. The signature did not verify, the version is foreign, or
+// the token has expired.
+// - ErrNotOurs — the service's own policy, not a status. See Validate.
+// - ErrForbidden — 403. The credential is good; it does not cover this action.
+// Distinct from 401 because retrying with the same token is pointless and
+// the holder needs to be told to ask for a wider grant, not to log in again.
+// - ErrRevoked — 401. The credential was withdrawn. Deliberately not 403: the
+// token is no longer a credential at all, and a client that sees 403 will
+// keep presenting it.
+// - ErrUnavailable — 503. tokens.sr.ht could not be asked.
+//
+// The 503 is the one that has to be defended, because "I could not check" reads
+// so naturally as "so I will not accept it". Reading an unreachable daemon as a
+// revocation would refuse every registered token on the instance for as long as
+// tokens.sr.ht is down — turning a restart of a service that is deliberately off
+// the hot path into an instance-wide outage of uploads. SPEC ch. 6 step 4 says
+// 503 for exactly that reason, following core-go, which answers the same way
+// when meta.sr.ht cannot be reached. 503 also says the true thing to a client:
+// come back, this is us and it is temporary.
+//
+// This is not a decision to fail open. A short token was never checked against
+// the daemon in the first place, and a registered one whose revocation cannot be
+// confirmed is refused — with a status that keeps the operator's attention on
+// the daemon instead of on a thousand clients being told their credentials are
+// bad.
+var (
+ // ErrInvalid: the presented string is not a token this instance sealed, or
+ // no longer is one. 401.
+ ErrInvalid = errors.New("bearer: token does not verify")
+
+ // ErrNotOurs: a well-formed token from another issuer, almost certainly a
+ // meta.sr.ht PAT. Returned together with the decoded token; the status is
+ // the service's to choose.
+ ErrNotOurs = errors.New("bearer: token was not issued by tokens.sr.ht")
+
+ // ErrForbidden: our token, valid, but it does not carry the action. 403.
+ ErrForbidden = errors.New("bearer: token does not grant this action")
+
+ // ErrRevoked: a registered token whose row is gone, revoked or expired. 401.
+ ErrRevoked = errors.New("bearer: token has been revoked")
+
+ // ErrUnavailable: the revocation check could not be completed. 503, never
+ // 401 — see above.
+ ErrUnavailable = errors.New("bearer: tokens.sr.ht could not be reached")
+)
+
+// Options configures a Validator.
+type Options struct {
+ // Origin is where tokens.sr.ht answers, scheme and host, e.g.
+ // "https://tokens.srht.bigb.es". Required.
+ Origin string
+
+ // ClientID identifies the *calling* service in the internal authorization of
+ // step 4, e.g. "bench.sr.ht". Required — the daemon's guard refuses a blob
+ // that names neither this nor NodeID.
+ //
+ // It is a label, not a credential: the guard admits every internal service
+ // equally and this only decides what its log line says. Which is precisely
+ // why it should be right; it is what an operator has to go on when the
+ // revocation cache misbehaves.
+ ClientID string
+
+ // NodeID identifies the calling process or host, e.g. "bench-1". Required,
+ // for the same reason and with the same weight as ClientID.
+ NodeID string
+
+ // HTTPClient performs the revocation check. Nil means a client with
+ // defaultHTTPTimeout.
+ HTTPClient *http.Client
+
+ // CacheTTL is how long one revocation answer is reused. Zero means
+ // DefaultCacheTTL; negative is refused.
+ CacheTTL time.Duration
+
+ // Now is the clock the cache ages entries against. Nil means time.Now.
+ //
+ // It does not move the expiry check of step 1: auth.DecodeBearerToken reads
+ // the real clock itself and this package cannot reach inside it. A test that
+ // wants an expired token has to mint one that is genuinely in the past.
+ Now func() time.Time
+}
+
+// Validator runs the check of SPEC ch. 6 for one calling service. It is safe for
+// concurrent use, which it has to be: a service holds exactly one and every
+// request handler goes through it.
+type Validator struct {
+ origin string
+ clientID string
+ nodeID string
+ client *http.Client
+ ttl time.Duration
+ now func() time.Time
+
+ mu sync.Mutex
+ cache map[int]verdict
+}
+
+// verdict is one cached answer from the revocation endpoint: whether the row was
+// live, and when that answer stops being reusable.
+//
+// ErrUnavailable never becomes a verdict — a failure to ask is not an answer,
+// and caching it would let one blip, one timeout, one restart pin every token
+// that happened to be checked during it to failure for the whole TTL. That turns
+// a moment of unavailability into a minute of it, and it does so silently,
+// because the daemon is healthy again while the services are still refusing.
+type verdict struct {
+ alive bool
+ until time.Time
+}
+
+// Token is what validation yields: who the holder is, what the token permits,
+// and how long it lasts.
+type Token struct {
+ // Username is the meta.sr.ht account the token was issued to. Resolving it
+ // to a local row is the service's job (auth.LookupUser).
+ Username string
+
+ // Grants is the parsed grant set.
+ //
+ // It is the zero value — which admits nothing — when the error is
+ // ErrNotOurs, and that is not an oversight. A foreign token's grant string is
+ // in whatever vocabulary its issuer uses, and meta.sr.ht's is core-go's
+ // auth.Grants ("git.sr.ht/OBJECTS:RW"), a different grammar that this one
+ // would reject as malformed. A service that accepts meta PATs must decode
+ // that string with auth.DecodeGrants; it can get at it by calling
+ // auth.DecodeBearerToken on the presented string, which is local and cheap.
+ Grants grants.Grants
+
+ // TokenID is the row id from the grant string's id: member, or 0 for a
+ // stateless token — one short enough that tokens.sr.ht never wrote it down
+ // and that therefore has no revocation to check (SPEC ch. 2).
+ TokenID int
+
+ // Expires is when the signature stops being honoured.
+ Expires time.Time
+}
+
+// Registered reports whether this token has a row at tokens.sr.ht — the
+// difference between a credential its owner can revoke and one that can only be
+// waited out.
+func (t *Token) Registered() bool { return t.TokenID != 0 }
+
+// New builds a Validator, refusing options that would only fail later, one
+// request at a time, as an error about the network.
+func New(opts Options) (*Validator, error) {
+ if opts.Origin == "" {
+ return nil, errors.New("bearer: Origin is required, e.g. https://tokens.srht.bigb.es")
+ }
+ u, err := url.Parse(opts.Origin)
+ if err != nil {
+ return nil, fmt.Errorf("bearer: Origin %q does not parse: %w", opts.Origin, err)
+ }
+ // An origin without a scheme and host is not one, and the failure it causes
+ // otherwise is a request error on the first registered token some service
+ // sees — days after the config was written, and reported as ErrUnavailable,
+ // which points the operator at the daemon rather than at the typo.
+ if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+ return nil, fmt.Errorf(
+ "bearer: Origin %q must be an absolute http(s) URL, e.g. https://tokens.srht.bigb.es",
+ opts.Origin)
+ }
+ if opts.ClientID == "" {
+ return nil, errors.New("bearer: ClientID is required: the internal guard refuses a blob without one")
+ }
+ if opts.NodeID == "" {
+ return nil, errors.New("bearer: NodeID is required: the internal guard refuses a blob without one")
+ }
+ if opts.CacheTTL < 0 {
+ return nil, fmt.Errorf("bearer: CacheTTL %s is negative; zero means %s", opts.CacheTTL, DefaultCacheTTL)
+ }
+
+ v := &Validator{
+ origin: strings.TrimSuffix(opts.Origin, "/"),
+ clientID: opts.ClientID,
+ nodeID: opts.NodeID,
+ client: opts.HTTPClient,
+ ttl: opts.CacheTTL,
+ now: opts.Now,
+ cache: make(map[int]verdict),
+ }
+ if v.client == nil {
+ v.client = &http.Client{Timeout: defaultHTTPTimeout}
+ }
+ if v.ttl == 0 {
+ v.ttl = DefaultCacheTTL
+ }
+ if v.now == nil {
+ v.now = time.Now
+ }
+ return v, nil
+}
+
+// Validate runs the four steps of SPEC ch. 6 against one presented token for one
+// action, e.g. "bench:upload".
+//
+// presented is the bare credential, with any "Bearer " scheme already stripped.
+//
+// On success it returns the token and a nil error. On failure it returns one of
+// this package's sentinels, wrapped with detail — test with errors.Is, and map
+// it to a status with the table on those sentinels.
+//
+// The returned token is nil for every failure except ErrNotOurs. A token that
+// failed validation is not a token, and handing one back invites a caller to use
+// what it was just told not to; the exception is the whole point of step 2,
+// where the token *did* validate and only the question of whose it is remains.
+//
+// # Step 2 is not decided here
+//
+// A token whose ClientID is not TokensClientID gets ErrNotOurs and the decoded
+// token, and this package takes it no further. That is deliberate and SPEC ch. 6
+// step 2 requires it: what to do with a foreign bearer token is per-service
+// policy, not a property of the format. dolt accepts meta.sr.ht PATs today and
+// must keep accepting them; bench and cover have no reason to. A validator that
+// refused on its own behalf would break the first and look correct doing it,
+// because the token really is not one of ours — it just is not this package's
+// call.
+//
+// So a service that also accepts meta PATs writes:
+//
+// tok, err := v.Validate(ctx, presented, "dolt:push")
+// if errors.Is(err, bearer.ErrNotOurs) {
+// // ... its own meta-PAT path ...
+// }
+//
+// and one that does not turns ErrNotOurs into 401 alongside ErrInvalid.
+func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) {
+ // Step 1. Signature, version and expiry, all of it local.
+ //
+ // DecodeBearerToken returns nil for all three and distinguishes none of
+ // them, which is the right amount of detail to give a client anyway. It
+ // checks the expiry itself, against the real clock — so an expired token
+ // costs one HMAC and never becomes a request to anybody. That property is
+ // what makes it safe for this step to run before every other.
+ bt := auth.DecodeBearerToken(presented)
+ if bt == nil {
+ return nil, fmt.Errorf("%w: signature, version or expiry", ErrInvalid)
+ }
+
+ // Step 2. Ours, or somebody else's? Not our decision — see the doc comment.
+ if bt.ClientID != TokensClientID {
+ return &Token{
+ Username: bt.Username,
+ Expires: bt.Expires.Time(),
+ }, fmt.Errorf("%w: ClientID is %q, not %q", ErrNotOurs, bt.ClientID, TokensClientID)
+ }
+
+ g, err := grants.Parse(bt.Grants)
+ if err != nil {
+ // Only tokens.sr.ht seals a token with our ClientID, and it writes the
+ // grant string with the same parser that is failing here, so this is
+ // either a version skew between daemon and service or a bug in one of
+ // them. Either way it is not a credential this service can act on.
+ return nil, fmt.Errorf("%w: grants %q do not parse: %s", ErrInvalid, bt.Grants, err)
+ }
+
+ tok := &Token{
+ Username: bt.Username,
+ Grants: g,
+ TokenID: g.TokenID(),
+ Expires: bt.Expires.Time(),
+ }
+
+ // Step 3. Does it cover what is being attempted?
+ if !g.Has(action) {
+ return nil, fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, g.String())
+ }
+
+ // Step 4. Only a registered token has a revocation to check. A stateless one
+ // has no row, so there is nothing to ask about and nothing to reach for:
+ // this is the common case and it completes without any network at all.
+ if tok.TokenID == 0 {
+ return tok, nil
+ }
+ if err := v.checkRevocation(ctx, tok.TokenID); err != nil {
+ return nil, err
+ }
+ return tok, nil
+}
+
+// Forget drops the cached revocation answer for one token id, so that the next
+// validation asks the daemon again.
+//
+// It is for the case where a service learns out of band that an answer is stale
+// — a webhook, an operator, a test — and wants the revocation to take effect now
+// rather than at the end of the TTL. Forgetting an id that is not cached is a
+// no-op, and forgetting one that is only costs a round trip.
+func (v *Validator) Forget(id int) {
+ v.mu.Lock()
+ delete(v.cache, id)
+ v.mu.Unlock()
+}
+
+// checkRevocation is step 4: ask GET {Origin}/api/v1/revocations/{id}, through
+// the cache.
+//
+// 204 is live, 404 is not, and everything else — a 500, a timeout, a refused
+// connection, a proxy's HTML error page — is ErrUnavailable. The endpoint has
+// exactly two answers by design (SPEC ch. 5), so anything that is neither is not
+// a third answer; it is the absence of one.
+//
+// Two concurrent validations of the same id will both issue a request when the
+// entry is cold. That is a duplicated round trip and nothing worse: the answers
+// agree, the second write to the cache is idempotent, and collapsing them would
+// buy one saved request in exchange for a dependency and a shared failure mode
+// where a single slow call holds up every goroutine waiting behind it.
+func (v *Validator) checkRevocation(ctx context.Context, id int) error {
+ if alive, ok := v.cached(id); ok {
+ if alive {
+ return nil
+ }
+ return fmt.Errorf("%w: token %d (cached)", ErrRevoked, id)
+ }
+
+ // The internal authorization is minted per request and cannot be cached: it
+ // is a fernet blob the daemon accepts only for thirty seconds, which is what
+ // stops a captured one being replayed for a week.
+ blob, err := json.Marshal(auth.InternalAuth{ClientID: v.clientID, NodeID: v.nodeID})
+ if err != nil {
+ return fmt.Errorf("%w: sealing the internal authorization: %s", ErrUnavailable, err)
+ }
+
+ url := v.origin + revocationPath + strconv.Itoa(id)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return fmt.Errorf("%w: building the request for %s: %s", ErrUnavailable, url, err)
+ }
+ req.Header.Set("Authorization", "Internal "+string(crypto.Encrypt(blob)))
+
+ resp, err := v.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("%w: asking %s: %s", ErrUnavailable, url, err)
+ }
+ defer resp.Body.Close()
+ // Both answers are empty bodies, but read to the end anyway so the
+ // connection goes back to the pool instead of being dropped and redialled on
+ // every upload.
+ _, _ = io.Copy(io.Discard, resp.Body)
+
+ switch resp.StatusCode {
+ case http.StatusNoContent:
+ v.remember(id, true)
+ return nil
+ case http.StatusNotFound:
+ // 404 covers revoked, expired and unknown alike, and all three are
+ // permanent: no id ever goes back to being live. Caching it is therefore
+ // not a staleness risk in the way caching "live" is.
+ v.remember(id, false)
+ return fmt.Errorf("%w: token %d", ErrRevoked, id)
+ default:
+ return fmt.Errorf("%w: %s answered %s", ErrUnavailable, url, resp.Status)
+ }
+}
+
+// cached returns a still-valid answer for id, if there is one.
+func (v *Validator) cached(id int) (alive, ok bool) {
+ now := v.now()
+
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ e, ok := v.cache[id]
+ if !ok || !now.Before(e.until) {
+ return false, false
+ }
+ return e.alive, true
+}
+
+// remember stores an answer for CacheTTL, and keeps the cache bounded.
+//
+// The TTL is the trade SPEC ch. 6 makes on purpose and it should be stated
+// plainly: a revocation takes up to CacheTTL to take effect across the instance.
+// The alternative is asking the daemon on every request, which puts tokens.sr.ht
+// back on the hot path of every upload and makes its availability the
+// instance's — the exact coupling SPEC ch. 1 removes. Sixty seconds of a revoked
+// token still working is the price of that, and the operator revoking it should
+// be told to expect it.
+//
+// The bound is a sweep, then a drop. At maxCacheEntries the expired entries go
+// first; if that does not get under the bound, the whole map goes. No LRU, no
+// eviction list — every entry here is worth exactly one HTTP round trip to
+// rebuild and they all expire within CacheTTL anyway, so the cost of throwing
+// away a full cache is bounded and small, while the cost of a map that only ever
+// grows is a leak in a process meant to run for months. In practice the bound
+// never fires: an entry can only be created by a token that already passed an
+// HMAC check, so the id space here is the daemon's real rows and not something a
+// caller can inflate.
+func (v *Validator) remember(id int, alive bool) {
+ now := v.now()
+
+ v.mu.Lock()
+ defer v.mu.Unlock()
+
+ if len(v.cache) >= maxCacheEntries {
+ for k, e := range v.cache {
+ if !now.Before(e.until) {
+ delete(v.cache, k)
+ }
+ }
+ if len(v.cache) >= maxCacheEntries {
+ v.cache = make(map[int]verdict, maxCacheEntries)
+ }
+ }
+ v.cache[id] = verdict{alive: alive, until: now.Add(v.ttl)}
+}
@@ 0,0 1,599 @@
+package bearer
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/auth"
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
+ "sourcecraft.dev/bigbes/sr-ht-core/crypto"
+)
+
+// The identity this validator claims when it calls the daemon — the calling
+// service, not tokens.sr.ht.
+const (
+ callerClientID = "bench.sr.ht"
+ callerNodeID = "bench-1"
+)
+
+// TestMain initialises the process-global core-go crypto state this package
+// depends on and never sets up itself.
+//
+// Both keys are load-bearing here and for different steps. [webhooks]private-key
+// is what auth.BearerToken.Encode signs with and what step 1 verifies against —
+// it is derived into the HMAC key, not used directly. [sr.ht]network-key is the
+// fernet key that seals the Internal authorization of step 4, so without it the
+// revocation tests would not get past the request. The values are the ones
+// core-go's own tests use.
+func TestMain(m *testing.M) {
+ config.FS = fstest.MapFS{
+ "config.ini": &fstest.MapFile{Data: []byte(`
+[webhooks]
+private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc=
+
+[sr.ht]
+network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk=
+`)},
+ }
+ crypto.InitCrypto(config.LoadConfig())
+ m.Run()
+}
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+// seal mints a token exactly the way tokens.sr.ht's service.seal does, so that
+// what these tests present is byte-for-byte the shape a real one has.
+func seal(username, clientID, grantString string, expires time.Time) string {
+ bt := &auth.BearerToken{
+ Version: auth.TokenVersion,
+ Expires: auth.ToTimestamp(expires),
+ Grants: grantString,
+ ClientID: clientID,
+ Username: username,
+ }
+ return bt.Encode()
+}
+
+// ourToken is a live working token from this instance's daemon.
+func ourToken(grantString string) string {
+ return seal("bigbes", TokensClientID, grantString, time.Now().Add(time.Hour))
+}
+
+// daemon is a stand-in for tokens.sr.ht's revocation endpoint. It records every
+// request so a test can assert not only what came back but whether anything was
+// asked at all — which for step 4 is half of what there is to check.
+//
+// statuses are consumed one per request, and the last one repeats forever, so
+// "fail once then recover" is written as newDaemon(t, 500, 204).
+type daemon struct {
+ server *httptest.Server
+
+ mu sync.Mutex
+ statuses []int
+ hits int
+ paths []string
+ auths []string
+}
+
+func newDaemon(t *testing.T, statuses ...int) *daemon {
+ t.Helper()
+ require.NotEmpty(t, statuses, "a daemon must be told what to answer")
+
+ d := &daemon{statuses: statuses}
+ d.server = httptest.NewServer(http.HandlerFunc(d.serve))
+ t.Cleanup(d.server.Close)
+ return d
+}
+
+func (d *daemon) serve(w http.ResponseWriter, r *http.Request) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ d.paths = append(d.paths, r.URL.Path)
+ d.auths = append(d.auths, r.Header.Get("Authorization"))
+
+ status := d.statuses[len(d.statuses)-1]
+ if d.hits < len(d.statuses) {
+ status = d.statuses[d.hits]
+ }
+ d.hits++
+ w.WriteHeader(status)
+}
+
+func (d *daemon) count() int {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return d.hits
+}
+
+func (d *daemon) lastPath(t *testing.T) string {
+ t.Helper()
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ require.NotEmpty(t, d.paths, "the daemon was never asked anything")
+ return d.paths[len(d.paths)-1]
+}
+
+func (d *daemon) lastAuth(t *testing.T) string {
+ t.Helper()
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ require.NotEmpty(t, d.auths, "the daemon was never asked anything")
+ return d.auths[len(d.auths)-1]
+}
+
+// fakeClock drives the cache's ageing. It does not drive step 1's expiry check:
+// auth.DecodeBearerToken reads the real clock and nothing here can reach it.
+type fakeClock struct {
+ mu sync.Mutex
+ t time.Time
+}
+
+func newClock() *fakeClock {
+ return &fakeClock{t: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)}
+}
+
+func (c *fakeClock) now() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.t
+}
+
+func (c *fakeClock) advance(d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.t = c.t.Add(d)
+}
+
+func newValidator(t *testing.T, origin string, now func() time.Time) *Validator {
+ t.Helper()
+ v, err := New(Options{
+ Origin: origin,
+ ClientID: callerClientID,
+ NodeID: callerNodeID,
+ Now: now,
+ })
+ require.NoError(t, err)
+ return v
+}
+
+// ---------------------------------------------------------------------------
+// Construction
+// ---------------------------------------------------------------------------
+
+func TestNewRefusesOptionsThatWouldOnlyFailLater(t *testing.T) {
+ good := Options{Origin: "https://tokens.srht.bigb.es", ClientID: callerClientID, NodeID: callerNodeID}
+
+ for name, mutate := range map[string]func(*Options){
+ "no origin": func(o *Options) { o.Origin = "" },
+ "no scheme": func(o *Options) { o.Origin = "tokens.srht.bigb.es" },
+ "wrong scheme": func(o *Options) { o.Origin = "ftp://tokens.srht.bigb.es" },
+ "no host": func(o *Options) { o.Origin = "https://" },
+ "no client id": func(o *Options) { o.ClientID = "" },
+ "no node id": func(o *Options) { o.NodeID = "" },
+ "negative ttl": func(o *Options) { o.CacheTTL = -time.Second },
+ "unparseable url": func(o *Options) { o.Origin = "https://%zz" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ opts := good
+ mutate(&opts)
+ v, err := New(opts)
+ require.Error(t, err, "a config mistake must not become a request-time 503")
+ assert.Nil(t, v)
+ })
+ }
+}
+
+func TestNewFillsTheDefaults(t *testing.T) {
+ v, err := New(Options{Origin: "https://tokens.srht.bigb.es/", ClientID: callerClientID, NodeID: callerNodeID})
+ require.NoError(t, err)
+
+ assert.Equal(t, DefaultCacheTTL, v.ttl, "a zero TTL means the 60s of SPEC ch. 6")
+ assert.NotNil(t, v.client)
+ assert.Equal(t, defaultHTTPTimeout, v.client.Timeout, "the default client must not be able to hang")
+ assert.NotNil(t, v.now)
+ assert.Equal(t, "https://tokens.srht.bigb.es", v.origin,
+ "a trailing slash must not become a double slash in the path")
+}
+
+// ---------------------------------------------------------------------------
+// Step 1: decode and verify
+// ---------------------------------------------------------------------------
+
+func TestTamperedTokenIsInvalid(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ good := ourToken("bench:upload id:42")
+
+ // Flip one character of the base64. The payload still decodes; the HMAC does
+ // not verify, which is the whole of what makes the format worth anything.
+ tampered := []byte(good)
+ if tampered[3] == 'A' {
+ tampered[3] = 'B'
+ } else {
+ tampered[3] = 'A'
+ }
+
+ for name, presented := range map[string]string{
+ "tampered": string(tampered),
+ "empty": "",
+ "not base64": "!!!not-a-token!!!",
+ "too short": "aGVsbG8",
+ "grants only": "bench:upload",
+ } {
+ t.Run(name, func(t *testing.T) {
+ tok, err := v.Validate(context.Background(), presented, "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err)
+ assert.Nil(t, tok, "a token that failed validation must not be handed back")
+ })
+ }
+ assert.Zero(t, d.count(), "nothing that fails to verify may become a request to the daemon")
+}
+
+// The expiry is checked by auth.DecodeBearerToken against the real clock, which
+// is why step 1 needs no network and no clock of ours.
+func TestExpiredTokenIsInvalidWithoutAskingAnybody(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ expired := seal("bigbes", TokensClientID, "bench:upload id:42", time.Now().Add(-time.Minute))
+
+ tok, err := v.Validate(context.Background(), expired, "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err)
+ assert.Nil(t, tok)
+ assert.Zero(t, d.count(), "an expired token is refused locally, before any network")
+}
+
+// ---------------------------------------------------------------------------
+// Step 2: is it ours?
+// ---------------------------------------------------------------------------
+
+// A meta.sr.ht PAT is signed with the same key and verifies perfectly. Only the
+// ClientID tells the two apart, and what to do about it is the service's policy
+// — so this returns the token along with the refusal.
+func TestForeignClientIDIsNotOursAndComesBackWithTheToken(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ expires := time.Now().Add(time.Hour)
+ // Meta's grant vocabulary, not ours: this string does not parse under
+ // grants.Parse, so a validator that checked ClientID after parsing would
+ // answer ErrInvalid here and a service like dolt would lose the ability to
+ // tell a foreign token from a broken one.
+ pat := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", expires)
+
+ tok, err := v.Validate(context.Background(), pat, "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrNotOurs), "want ErrNotOurs, got %v", err)
+
+ require.NotNil(t, tok, "the caller has to be able to look at the token it may still accept")
+ assert.Equal(t, "bigbes", tok.Username)
+ assert.Equal(t, expires.UTC().Truncate(time.Second), tok.Expires)
+ assert.True(t, tok.Grants.Empty(),
+ "a foreign grant string is in a foreign vocabulary; this one must admit nothing")
+ assert.Zero(t, tok.TokenID)
+ assert.Zero(t, d.count(), "a token that is not ours has no revocation of ours to check")
+}
+
+// ---------------------------------------------------------------------------
+// Step 3: the grant
+// ---------------------------------------------------------------------------
+
+func TestMissingGrantIsForbiddenAndStopsBeforeTheNetwork(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ // Registered, so step 4 would have work to do — and must not get the chance.
+ tok, err := v.Validate(context.Background(), ourToken("bench:read id:42"), "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrForbidden), "want ErrForbidden, got %v", err)
+ assert.Nil(t, tok)
+ assert.Zero(t, d.count(),
+ "step 3 refuses locally; a token that cannot do the thing must not cost a round trip")
+}
+
+func TestUniversalGrantAdmitsTheAction(t *testing.T) {
+ v := newValidator(t, "https://tokens.srht.bigb.es", nil)
+
+ tok, err := v.Validate(context.Background(), ourToken("*"), "bench:upload")
+ require.NoError(t, err)
+ assert.True(t, tok.Grants.All())
+}
+
+// ---------------------------------------------------------------------------
+// Step 4: revocation
+// ---------------------------------------------------------------------------
+
+// The common case: a short token was never written down, so there is nothing to
+// ask and nobody to ask it of. This is the property that keeps tokens.sr.ht off
+// the hot path (SPEC ch. 1), so the assertion that matters is the request count.
+func TestStatelessTokenNeverTouchesTheNetwork(t *testing.T) {
+ d := newDaemon(t, http.StatusNotFound) // would refuse, if it were ever asked
+ v := newValidator(t, d.server.URL, nil)
+
+ tok, err := v.Validate(context.Background(), ourToken("bench:upload cover:read"), "bench:upload")
+ require.NoError(t, err)
+
+ assert.Equal(t, "bigbes", tok.Username)
+ assert.Zero(t, tok.TokenID)
+ assert.False(t, tok.Registered())
+ assert.True(t, tok.Grants.Has("cover:read"))
+ assert.Zero(t, d.count(), "a token with no id: must not reach the daemon at all")
+}
+
+func TestRegisteredTokenValidatesAgainstA204(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
+ require.NoError(t, err)
+
+ assert.Equal(t, 42, tok.TokenID)
+ assert.True(t, tok.Registered())
+ assert.Equal(t, 1, d.count())
+ assert.Equal(t, "/api/v1/revocations/42", d.lastPath(t))
+}
+
+func TestRevokedTokenIsRefused(t *testing.T) {
+ d := newDaemon(t, http.StatusNotFound)
+ v := newValidator(t, d.server.URL, nil)
+
+ tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrRevoked), "want ErrRevoked, got %v", err)
+ assert.Nil(t, tok)
+ assert.Equal(t, 1, d.count())
+}
+
+// A daemon that cannot answer is not a daemon that answered "revoked". Getting
+// this wrong would refuse every registered token on the instance for the length
+// of a tokens.sr.ht restart, which is why each case asserts what the error is
+// *not* as well as what it is.
+func TestADaemonThatCannotAnswerIsUnavailableAndNeverRevoked(t *testing.T) {
+ t.Run("500", func(t *testing.T) {
+ d := newDaemon(t, http.StatusInternalServerError)
+ v := newValidator(t, d.server.URL, nil)
+
+ tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
+ assert.False(t, errors.Is(err, ErrRevoked), "unknown is not revoked")
+ assert.Nil(t, tok)
+ })
+
+ t.Run("401 from the internal guard", func(t *testing.T) {
+ // A misconfigured internal-ipnet, or a network key that does not match.
+ // It is a deployment fault and the operator has to see it as one, not as
+ // every user's token suddenly being revoked.
+ d := newDaemon(t, http.StatusUnauthorized)
+ v := newValidator(t, d.server.URL, nil)
+
+ _, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
+ assert.False(t, errors.Is(err, ErrRevoked))
+ })
+
+ t.Run("nothing listening", func(t *testing.T) {
+ dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ origin := dead.URL
+ dead.Close() // the port is now refusing connections
+
+ v := newValidator(t, origin, nil)
+
+ tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
+ assert.False(t, errors.Is(err, ErrRevoked), "a transport failure is not an answer")
+ assert.Nil(t, tok)
+ })
+
+ t.Run("context cancelled", func(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := v.Validate(ctx, ourToken("bench:upload id:42"), "bench:upload")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
+ assert.False(t, errors.Is(err, ErrRevoked))
+ })
+}
+
+// The internal authorization is the credential that gets past the daemon's
+// guard, and it has to name the *calling* service. If it named something else
+// the guard would still admit it — it admits every internal service equally —
+// and the daemon's log would quietly attribute every check to the wrong caller.
+func TestTheInternalBlobNamesTheConfiguredCaller(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ _, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
+ require.NoError(t, err)
+
+ scheme, blob, found := strings.Cut(d.lastAuth(t), " ")
+ require.True(t, found, "the header must be %q, got %q", "Internal <blob>", d.lastAuth(t))
+ assert.Equal(t, "Internal", scheme)
+
+ // Decrypted the way authn.InternalGuard does it, expiry window included: the
+ // blob is minted per request precisely so that this window is meaningful.
+ payload := crypto.DecryptWithExpiration([]byte(blob), 30*time.Second)
+ require.NotNil(t, payload, "the blob must verify under the instance's network key, and be fresh")
+
+ var ia auth.InternalAuth
+ require.NoError(t, json.Unmarshal(payload, &ia))
+ assert.Equal(t, callerClientID, ia.ClientID)
+ assert.Equal(t, callerNodeID, ia.NodeID)
+}
+
+// ---------------------------------------------------------------------------
+// The cache
+// ---------------------------------------------------------------------------
+
+func TestARevocationAnswerIsCachedWithinTheTTL(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ clock := newClock()
+ v := newValidator(t, d.server.URL, clock.now)
+
+ token := ourToken("bench:upload id:42")
+ for i := 0; i < 5; i++ {
+ _, err := v.Validate(context.Background(), token, "bench:upload")
+ require.NoError(t, err)
+ }
+ assert.Equal(t, 1, d.count(), "five validations inside the TTL are one question")
+
+ clock.advance(DefaultCacheTTL - time.Second)
+ _, err := v.Validate(context.Background(), token, "bench:upload")
+ require.NoError(t, err)
+ assert.Equal(t, 1, d.count(), "still inside the TTL")
+
+ clock.advance(2 * time.Second) // now past it
+ _, err = v.Validate(context.Background(), token, "bench:upload")
+ require.NoError(t, err)
+ assert.Equal(t, 2, d.count(), "past the TTL the daemon must be asked again")
+}
+
+// The trade SPEC ch. 6 makes on purpose, written down as a test so that nobody
+// has to rediscover it during an incident: a revocation takes up to CacheTTL to
+// be honoured by a service that already asked.
+func TestARevocationTakesUpToTheTTLToTakeEffect(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent, http.StatusNotFound)
+ clock := newClock()
+ v := newValidator(t, d.server.URL, clock.now)
+
+ token := ourToken("bench:upload id:42")
+
+ _, err := v.Validate(context.Background(), token, "bench:upload")
+ require.NoError(t, err)
+
+ // The owner revokes it here. The daemon would now answer 404 — but it is not
+ // being asked.
+ clock.advance(DefaultCacheTTL / 2)
+ _, err = v.Validate(context.Background(), token, "bench:upload")
+ assert.NoError(t, err, "inside the TTL the cached 'live' still stands")
+ assert.Equal(t, 1, d.count())
+
+ clock.advance(DefaultCacheTTL)
+ _, err = v.Validate(context.Background(), token, "bench:upload")
+ assert.True(t, errors.Is(err, ErrRevoked), "past the TTL it must be refused; got %v", err)
+ assert.Equal(t, 2, d.count())
+}
+
+// ErrUnavailable must never be cached. If it were, a single blip would pin every
+// token checked during it to failure for a full TTL — turning a moment of
+// unavailability into a minute of it, with the daemon healthy the whole time.
+func TestUnavailableIsNotCachedWhileALiveAnswerIs(t *testing.T) {
+ d := newDaemon(t, http.StatusInternalServerError, http.StatusNoContent)
+ clock := newClock()
+ v := newValidator(t, d.server.URL, clock.now)
+
+ token := ourToken("bench:upload id:42")
+
+ _, err := v.Validate(context.Background(), token, "bench:upload")
+ require.True(t, errors.Is(err, ErrUnavailable), "got %v", err)
+ assert.Equal(t, 1, d.count())
+
+ // Immediately afterwards, no clock movement at all: the failure left nothing
+ // behind, so the daemon is asked again and the recovery is picked up at once.
+ _, err = v.Validate(context.Background(), token, "bench:upload")
+ require.NoError(t, err, "a failure to ask must not be remembered as an answer")
+ assert.Equal(t, 2, d.count())
+
+ // And the answer that *is* an answer is cached.
+ _, err = v.Validate(context.Background(), token, "bench:upload")
+ require.NoError(t, err)
+ assert.Equal(t, 2, d.count(), "the 204 must be cached")
+}
+
+func TestForgetDropsOneCachedAnswer(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent, http.StatusNoContent, http.StatusNotFound)
+ clock := newClock()
+ v := newValidator(t, d.server.URL, clock.now)
+
+ first := ourToken("bench:upload id:42")
+ second := ourToken("bench:upload id:43")
+
+ _, err := v.Validate(context.Background(), first, "bench:upload")
+ require.NoError(t, err)
+ _, err = v.Validate(context.Background(), second, "bench:upload")
+ require.NoError(t, err)
+ require.Equal(t, 2, d.count())
+
+ v.Forget(42)
+
+ // 42 is asked again — and the daemon has moved on to answering 404.
+ _, err = v.Validate(context.Background(), first, "bench:upload")
+ assert.True(t, errors.Is(err, ErrRevoked), "got %v", err)
+ assert.Equal(t, 3, d.count())
+
+ // 43 was not forgotten and is still answered from the cache.
+ _, err = v.Validate(context.Background(), second, "bench:upload")
+ assert.NoError(t, err)
+ assert.Equal(t, 3, d.count())
+
+ v.Forget(9999) // forgetting what was never cached is a no-op
+}
+
+// The cache must not be a leak in a process that runs for months. The bound is
+// a sweep of the expired entries, then — if that was not enough — dropping the
+// lot, because every entry costs exactly one round trip to rebuild.
+func TestTheCacheIsBounded(t *testing.T) {
+ clock := newClock()
+ v := newValidator(t, "https://tokens.srht.bigb.es", clock.now)
+
+ for id := 1; id <= maxCacheEntries*2; id++ {
+ v.remember(id, true)
+ }
+
+ v.mu.Lock()
+ size := len(v.cache)
+ v.mu.Unlock()
+ assert.LessOrEqual(t, size, maxCacheEntries, "the cache must not grow without limit")
+ assert.NotZero(t, size, "and it must still be a cache afterwards")
+}
+
+// A service holds one Validator and every request handler goes through it, so
+// "safe for concurrent use" is a requirement rather than a nicety. Worth running
+// under -race.
+func TestValidatorIsSafeForConcurrentUse(t *testing.T) {
+ d := newDaemon(t, http.StatusNoContent)
+ v := newValidator(t, d.server.URL, nil)
+
+ tokens := []string{
+ ourToken("bench:upload id:42"),
+ ourToken("bench:upload id:43"),
+ ourToken("bench:upload"),
+ }
+
+ var wg sync.WaitGroup
+ for i := 0; i < 32; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < 8; j++ {
+ _, err := v.Validate(context.Background(), tokens[(i+j)%len(tokens)], "bench:upload")
+ assert.NoError(t, err)
+ }
+ v.Forget(42)
+ }(i)
+ }
+ wg.Wait()
+}