// 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 } // 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. 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) { 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. 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)} }