// 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"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-ecore/grants"
"sourcecraft.dev/bigbes/sr-ht-ecore/internalauth"
)
// 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 }
// Authorize is step 3 of SPEC ch. 6, split out so that it can be asked where the
// action is known — in the handler that implements it — rather than in the
// middleware that resolved the identity. See Inspect.
//
// It reports ErrForbidden and not ErrInvalid: the credential is good and the
// caller is who they say they are, and what is missing is a permission. That
// distinction is what stops an agent retrying forever with a token that will
// never grow the grant it needs.
func (t *Token) Authorize(action string) error {
if !t.Grants.Has(action) {
return fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, t.Grants.String())
}
return nil
}
// 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, and the policy varies by *surface* and
// not only by service. Every GraphQL endpoint on the instance accepts meta PATs,
// because api.sr.ht forwards one client credential to every service a federated
// query touches, so one that refused them could not be federated at all; the
// REST and MCP surfaces beside them accept only working tokens, where a narrow,
// revocable grant is worth its cost. A validator that refused on its own behalf
// would break the first kind 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 — see the metapat package ...
// }
//
// and one that does not turns ErrNotOurs into 401 alongside ErrInvalid.
//
// Routing on this error is the second-best way to do it, and metapat.PlaneOf is
// the first: an instance with no [tokens.sr.ht] section holds no Validator to
// call, and its meta PAT plane has to keep working anyway.
func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) {
tok, err := decodeOurs(presented)
if err != nil {
return tok, err
}
// Step 3 before step 4, deliberately: a token that does not carry the grant
// is refused without a round trip to the daemon. Inspect cannot keep this
// ordering — it has no action to check — which is the one cost of the split
// and the reason this method still exists for callers that know both.
if err := tok.Authorize(action); err != nil {
return nil, err
}
if err := v.revoked(ctx, tok); err != nil {
return nil, err
}
return tok, nil
}
// Inspect is Validate without step 3: it answers who a token belongs to, what it
// may do, and whether it is still live — and leaves the question of whether that
// covers *this* action to the caller.
//
// It exists because of where the two questions get answered in a sourcehut
// service. Identity is resolved once per request in middleware, upstream of the
// router: that is where the cookie plane and the bearer plane meet and where a
// principal is put on the context, and at that point nothing knows yet which
// route will run, so nothing knows the action. The action is known one layer
// down, in the handler that implements it. A validator that insisted on both at
// once would force every service either to invent an action before it has one,
// or to lift its bearer plane out of the middleware every other plane goes
// through — and the second is how a surface ends up with two different ideas of
// who is calling.
//
// So: call Inspect in the resolver and carry the Grants on the principal, then
// call Token.Authorize in the handler. Validate stays for callers that know both
// at one point, and is exactly those two calls.
//
// Everything Validate's doc comment says — about the sentinels, about step 2
// being per-service policy, and about the returned token being nil for every
// failure except ErrNotOurs — applies here unchanged.
func (v *Validator) Inspect(ctx context.Context, presented string) (*Token, error) {
tok, err := decodeOurs(presented)
if err != nil {
return tok, err
}
if err := v.revoked(ctx, tok); err != nil {
return nil, err
}
return tok, nil
}
// revoked is step 4: a registered token has a revocation to ask about, a
// stateless one has no row and so completes without any network at all — which
// is the common case under the default configuration.
func (v *Validator) revoked(ctx context.Context, tok *Token) error {
if tok.TokenID == 0 {
return nil
}
return v.checkRevocation(ctx, tok.TokenID)
}
// decodeOurs is steps 1 and 2 plus the grant parse: everything that can be
// decided from the token itself, with no clock but the real one and no network
// at all.
func decodeOurs(presented 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(),
}
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.
//
// Through internalauth rather than assembled here, so that this caller and
// every Guard on the instance read one definition of the payload. Minting it
// by hand next to a package whose whole purpose is to hold both ends of this
// handshake is the drift that package exists to prevent.
authorization, err := internalauth.Authorization(v.clientID, 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", authorization)
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)}
}