diff --git a/bearer/bearer.go b/bearer/bearer.go index d974dddd0384cbc69de6e043f2f30958b37bb31d..7749089f640673465e0930c954d03d38df48d7d9 100644 --- a/bearer/bearer.go +++ b/bearer/bearer.go @@ -323,20 +323,27 @@ // // 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. +// 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 ... +// // ... 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 { diff --git a/metapat/metapat.go b/metapat/metapat.go new file mode 100644 index 0000000000000000000000000000000000000000..ea41cf949aeb533f6f7146a42d89b14023d4d4f3 --- /dev/null +++ b/metapat/metapat.go @@ -0,0 +1,553 @@ +// Package metapat validates a meta.sr.ht personal access token, and is the one +// copy of that check for every service on the instance that accepts one beside +// a tokens.sr.ht working token. +// +// # Why a service needs both planes +// +// The instance seals two bearer shapes with the same key, and only the ClientID +// tells them apart (bearer.TokensClientID). Which one a surface accepts is not a +// matter of taste: +// +// - api.sr.ht forwards ONE client "Authorization" header to every service a +// federated query touches — its AuthMiddleware copies the header verbatim +// into the request context, and the Internal credential it can mint is used +// only to fetch schemas at startup. So a federated query carries whatever +// credential the client had, to all of its services at once. A GraphQL +// endpoint that refuses meta PATs therefore cannot be federated: the first +// authenticated query that reaches it answers 401. +// - Every upstream service on the instance authenticates machine callers with +// a meta PAT, so a meta PAT is the only credential a client can hold that +// works instance-wide. +// +// A tokens.sr.ht working token remains the credential of the surfaces that are +// not federated — the REST uploads and the MCP endpoints — because those are +// where a narrow, short-lived, revocable grant is worth its cost. This package +// is what lets one service hold both without writing the PAT path four times. +// +// # What this package does and does not decide +// +// It answers exactly one question: is this presented string a live meta.sr.ht +// personal access token, and whose? Resolving that into the service's own notion +// of a caller, choosing an HTTP status for each refusal, and deciding what the +// caller may then see are all the service's, as they are for bearer. +// +// The steps, in order, and the order is the point — everything that can refuse +// locally runs before anything that touches the network: +// +// 1. decode and verify the signature and expiry (local, no network); +// 2. is this a PAT at all, or a working token wearing the same envelope? +// 3. mirror the owner's profile from meta.sr.ht; +// 4. ask meta.sr.ht whether the token has been revoked. +// +// Steps 3 and 4 are cached together for CacheTTL, so a burst of federated +// queries carrying one PAT costs one pair of lookups rather than one per field +// resolver. +// +// Scope enforcement is deliberately not part of resolution. A PAT carries +// core-go's OAuth grant vocabulary ("cov.sr.ht/REPORTS:RO"), the surface knows +// which scope and which mode it is about to exercise, and Allows is where the +// two meet. +// +// Usage: +// +// v, err := metapat.New(metapat.Options{Service: "cov.sr.ht"}) +// ... +// switch metapat.PlaneOf(presented) { +// case metapat.PlaneWorking: +// tok, err := workingTokens.Inspect(ctx, presented) +// ... +// case metapat.PlaneMeta: +// ac, err := v.Resolve(ctx, presented) +// if err == nil && !metapat.Allows(ac, "cov.sr.ht/REPORTS", auth.RO) { +// // 403 +// } +// } +// +// The process must have run crypto.InitCrypto before any of this: the signing +// key step 1 verifies against lives 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. +package metapat + +import ( + "context" + "crypto/sha512" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/vaughan0/go-ini" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/config" + + "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" +) + +// DefaultCacheTTL is how long one resolution is reused when Options leaves +// CacheTTL at zero. +// +// Sixty seconds, matching the figure the tokens SPEC names for the working-token +// revocation check, and for the same trade: it bounds how long a revoked +// credential keeps working, against how hard a single agent's request loop hits +// meta.sr.ht. +const DefaultCacheTTL = 60 * time.Second + +// maxCacheEntries bounds the resolution 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 this package, 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 they are spelled to mirror bearer's, so that a +// service holding both planes writes one classification table and not two: +// +// - ErrInvalid — 401. The signature did not verify, the token has expired, or +// it names an account meta.sr.ht will not resolve. +// - ErrNotOurs — the service's own policy, not a status. See Resolve. +// - ErrForbidden — 403. The credential is good; its OAuth grants do not cover +// what is being attempted. Returned by callers of Allows, never by Resolve. +// - ErrRevoked — 401. The token was withdrawn by its owner. +// - ErrUnavailable — 503. meta.sr.ht could not be asked. +// +// The 503 is the one that has to be defended, for the reason bearer's own +// sentinels give at length: "I could not check" is not "your credential is bad", +// and answering 401 to a meta.sr.ht outage tells every client on the instance to +// go and re-mint credentials that were never broken. It is also what core-go +// itself answers when meta cannot be reached, so a service that classifies this +// way stays consistent with the upstream services beside it. +var ( + // ErrInvalid: the presented string is not a personal access token this + // instance sealed, or no longer is one. 401. + ErrInvalid = errors.New("metapat: token does not verify") + + // ErrNotOurs: a well-formed token sealed by tokens.sr.ht rather than by + // meta.sr.ht. Returned so that a service which routes on failure rather than + // on PlaneOf still gets a total answer; the status is the service's to + // choose, and for a service holding both planes it is not a refusal at all. + ErrNotOurs = errors.New("metapat: token was issued by tokens.sr.ht, not meta.sr.ht") + + // ErrForbidden: the token is good and does not carry the scope. 403. This + // package returns it from no function — Allows answers a bool — and it is + // exported so that the service's refusal has a sentinel to wrap that belongs + // to the same table as the rest. + ErrForbidden = errors.New("metapat: token does not carry the required OAuth scope") + + // ErrRevoked: meta.sr.ht reports the token as revoked. 401 and not 403: it + // is no longer a credential at all, and a client shown 403 will keep + // presenting it. + ErrRevoked = errors.New("metapat: token has been revoked") + + // ErrUnavailable: the profile mirror or the revocation check could not be + // completed. 503, never 401 — see above. + ErrUnavailable = errors.New("metapat: meta.sr.ht could not be reached") +) + +// Plane says which of the instance's two bearer planes sealed a credential. +type Plane int + +const ( + // PlaneUnknown: the string does not decode as a bearer token this instance + // sealed at all — forged, corrupted, expired, or simply not a token. + PlaneUnknown Plane = iota + + // PlaneWorking: a tokens.sr.ht working token. + PlaneWorking + + // PlaneMeta: a meta.sr.ht personal access token. + PlaneMeta +) + +// String names the plane, for a log line. +func (p Plane) String() string { + switch p { + case PlaneWorking: + return "tokens.sr.ht working token" + case PlaneMeta: + return "meta.sr.ht personal access token" + default: + return "unrecognised credential" + } +} + +// PlaneOf reports which plane a presented credential belongs to, without +// resolving it — one local HMAC and no network. +// +// This is how a service routes, and routing here rather than on the failure of +// one plane matters for a reason that is easy to miss: an instance whose config +// has no [tokens.sr.ht] section holds no working-token validator at all, and its +// meta PAT plane must keep working anyway. A service that routed by calling the +// working-token validator first and catching bearer.ErrNotOurs would have +// nothing to call. +// +// PlaneUnknown is not a verdict about the credential's issuer, only about this +// process's ability to read it. An expired token of either plane lands here, +// because auth.DecodeBearerToken checks expiry before it reports anything — so a +// service should answer PlaneUnknown with the same 401 it gives ErrInvalid, +// rather than treating it as "no credential presented". +func PlaneOf(presented string) Plane { + bt := auth.DecodeBearerToken(presented) + if bt == nil { + return PlaneUnknown + } + if bt.ClientID == bearer.TokensClientID { + return PlaneWorking + } + return PlaneMeta +} + +// Backend is the meta.sr.ht half of the check, declared here as an interface so +// that every arm of Resolve is testable without a meta.sr.ht, without a network +// and without a database. +// +// Both methods are core-go calls in production (CoreBackend), and both may hit +// the network: LookupUser falls back to an internal GraphQL query when the local +// mirror misses, and IsRevoked always asks. +type Backend interface { + // LookupUser mirrors a meta.sr.ht profile into out, filling in at least + // UserID and Username. An error is transient by contract — the account may + // exist and meta may simply be unreachable. + LookupUser(ctx context.Context, username string, out *auth.AuthContext) error + + // IsRevoked reports whether the personal access token with this sha512 has + // been revoked by its owner. clientID is the token's own, which is what + // scopes the revocation row. + IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) +} + +// coreBackend is the production Backend: core-go, unadorned. +type coreBackend struct{} + +// Compile-time proof that the production backend satisfies the port. Its two +// methods are the only lines in this package a test cannot reach — they need a +// meta.sr.ht — so this is what stands between them and a signature drift. +var _ Backend = coreBackend{} + +func (coreBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error { + return auth.LookupUser(ctx, username, out) +} + +func (coreBackend) IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) { + return auth.LookupTokenRevocation(ctx, username, hash, clientID) +} + +// CoreBackend returns the production backend, the one Options selects when +// Backend is nil. It is exported so that a service wrapping it — to add a metric +// or a log line — has something to embed. +func CoreBackend() Backend { return coreBackend{} } + +// Options configures a Validator. +type Options struct { + // Service is this service's name as meta.sr.ht spells it in a grant, + // e.g. "cov.sr.ht". Required. + // + // It is required for a reason that is invisible until it is not: + // auth.DecodeGrants reads the *calling* service's name off the context, to + // expand a grant written without one, and config.ServiceName PANICS rather + // than returning "" when nothing put it there. In production nothing puts it + // there except core-go's config.Middleware, so a validator that relied on the + // ambient context would work behind an HTTP router and take the process down + // anywhere else — a background job, a CLI, a test. Naming the service here + // makes this package answerable to its own caller instead. + Service string + + // Backend performs the meta.sr.ht lookups. Nil means CoreBackend(). + Backend Backend + + // CacheTTL is how long one resolution 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 resolves meta.sr.ht personal access tokens for one 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 { + service string + backend Backend + ttl time.Duration + now func() time.Time + + mu sync.Mutex + cache map[[64]byte]entry +} + +// entry is one cached resolution: the caller it produced, and when that stops +// being reusable. +// +// Only successes are cached. A failure to reach meta is not an answer, and +// caching it would let one blip pin every token checked during it to failure for +// the whole TTL — turning a moment of unavailability into a minute of it, while +// meta is already healthy again. A genuine refusal is not cached either: it +// costs one local HMAC to reproduce, and the alternative is a data structure +// that an attacker can grow by presenting garbage. +type entry struct { + ac *auth.AuthContext + until time.Time +} + +// New builds a Validator, refusing options that would only fail later. +func New(opts Options) (*Validator, error) { + if opts.Service == "" { + return nil, errors.New( + "metapat: Service is required, e.g. cov.sr.ht: decoding a grant string needs it") + } + if opts.CacheTTL < 0 { + return nil, fmt.Errorf("metapat: CacheTTL %s is negative; zero means %s", + opts.CacheTTL, DefaultCacheTTL) + } + + v := &Validator{ + service: opts.Service, + backend: opts.Backend, + ttl: opts.CacheTTL, + now: opts.Now, + cache: make(map[[64]byte]entry), + } + if v.backend == nil { + v.backend = CoreBackend() + } + if v.ttl == 0 { + v.ttl = DefaultCacheTTL + } + if v.now == nil { + v.now = time.Now + } + return v, nil +} + +// Resolve runs the four steps against one presented personal access token. +// +// presented is the bare credential, with any "Bearer " scheme already stripped. +// +// On success it returns an *auth.AuthContext with AuthMethod, BearerToken, +// TokenHash and Grants filled in — the same shape core-go's own OAuth2 +// middleware produces, so that everything downstream which already understands +// an OAuth2 caller keeps working, Allows included. +// +// On failure it returns one of this package's sentinels, wrapped with detail: +// test with errors.Is and map to a status with the table on those sentinels. The +// returned context is nil for every failure, including ErrNotOurs — a service +// that meant to accept a working token must route with PlaneOf and call its +// working-token validator, which is the only thing that can check one. +// +// # What is not checked here +// +// The token's own username is taken as the identity. There is no second name in +// a bearer header to compare it against — that check belongs to the Basic-auth +// flows, where a token is presented as somebody's password and the point is to +// stop it being presented as somebody else's. +// +// The OAuth scope is not checked either. See Allows. +func (v *Validator) Resolve(ctx context.Context, presented string) (*auth.AuthContext, error) { + if presented == "" { + return nil, fmt.Errorf("%w: no token presented", ErrInvalid) + } + + hash := sha512.Sum512([]byte(presented)) + if ac, ok := v.cached(hash); ok { + return ac, nil + } + + // Step 1, local: signature and expiry. A forged or expired credential costs + // one HMAC and never becomes a request to meta.sr.ht. + bt := auth.DecodeBearerToken(presented) + if bt == nil { + return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalid) + } + + // Step 2. Refused rather than attempted: a working token's grant string is + // in tokens.sr.ht's vocabulary, which auth.DecodeGrants would reject as + // malformed, and its revocation row lives at a different daemon entirely. + if bt.ClientID == bearer.TokensClientID { + return nil, fmt.Errorf("%w: ClientID is %q", ErrNotOurs, bt.ClientID) + } + + // Step 3. The token names a meta.sr.ht account; turning that into a local + // row is core-go's job, through the same call every other plane makes. + var ac auth.AuthContext + if err := v.backend.LookupUser(ctx, bt.Username, &ac); err != nil { + // Transient. The credential is good, and telling an agent to re-mint + // over a lookup outage is the wrong instruction twice: it does not help, + // and it destroys a working credential. + return nil, fmt.Errorf("%w: looking up user %q: %w", ErrUnavailable, bt.Username, err) + } + if ac.UserID == 0 { + // LookupUser answered without filling in an id. Nothing downstream can + // use that: every ownership row keys on the user id, and a zero would + // match whichever row has an unset owner. Permanent rather than + // transient — retrying will not conjure the account back. + return nil, fmt.Errorf("%w: token names %q, for whom no meta id was mirrored", + ErrInvalid, bt.Username) + } + + // Step 4. + revoked, err := v.backend.IsRevoked(ctx, bt.Username, hash, bt.ClientID) + if err != nil { + return nil, fmt.Errorf("%w: checking revocation for %q: %w", ErrUnavailable, bt.Username, err) + } + if revoked { + return nil, fmt.Errorf("%w: token of %q", ErrRevoked, bt.Username) + } + + grants, err := auth.DecodeGrants(v.grantContext(ctx), bt.Grants) + if err != nil { + return nil, fmt.Errorf("%w: decoding token grants: %w", ErrInvalid, err) + } + + ac.AuthMethod = auth.AUTH_OAUTH2 + ac.BearerToken = bt + ac.TokenHash = hash + ac.Grants = grants + + v.remember(hash, &ac) + return copyOf(&ac), nil +} + +// grantContext derives the context auth.DecodeGrants insists on: one naming the +// calling service, which it uses to expand a grant written without a service +// prefix, and which config.ServiceName panics for the absence of. +// +// The config half is deliberately empty. DecodeGrants reads only the name, the +// derived context never leaves this call, and carrying a real ini.File through +// Options just to satisfy a field nothing reads would make every caller supply +// one. If core-go ever starts reading the config here, this is where it stops +// being enough — which is why it is one named function and not an inline +// expression. +// +// Overwriting rather than inspecting is forced: both context keys are +// unexported, so there is no way to ask whether a name is already present that +// does not go through the function that panics. Overwriting is also correct — +// what would already be there is this same service's name, put there by +// config.Middleware on the request path. +func (v *Validator) grantContext(ctx context.Context) context.Context { + return config.Context(ctx, ini.File{}, v.service) +} + +// Forget drops any cached resolution of this token, so that the next Resolve +// asks meta.sr.ht again. +// +// It exists for the service that learns out of band — from a webhook, from its +// own revocation UI — that a credential has changed, and would otherwise keep +// honouring it for up to CacheTTL. Forgetting a token that was never cached is a +// no-op rather than an error. +func (v *Validator) Forget(presented string) { + hash := sha512.Sum512([]byte(presented)) + v.mu.Lock() + defer v.mu.Unlock() + delete(v.cache, hash) +} + +// cached returns a live cached resolution, if there is one. +func (v *Validator) cached(hash [64]byte) (*auth.AuthContext, bool) { + v.mu.Lock() + defer v.mu.Unlock() + + e, ok := v.cache[hash] + if !ok { + return nil, false + } + if !v.now().Before(e.until) { + delete(v.cache, hash) + return nil, false + } + return copyOf(e.ac), true +} + +// remember caches one successful resolution. +// +// At maxCacheEntries the cache is dropped whole rather than evicted by age. The +// bound is not a tuning knob and reaching it is not the steady state: a service +// sees a handful of distinct credentials, and four thousand of them means either +// an instance far larger than this one or a caller minting a token per request. +// Dropping everything costs one round of re-resolution and cannot degrade into +// the thing an LRU can — a cache that spends more time evicting than answering, +// under exactly the load that filled it. +func (v *Validator) remember(hash [64]byte, ac *auth.AuthContext) { + v.mu.Lock() + defer v.mu.Unlock() + + if len(v.cache) >= maxCacheEntries { + v.cache = make(map[[64]byte]entry, maxCacheEntries) + } + v.cache[hash] = entry{ac: copyOf(ac), until: v.now().Add(v.ttl)} +} + +// copyOf returns a shallow copy, so that a caller which annotates the context it +// was handed — core-go's own middleware sets IPAddress on one — does not write +// through into the cache and hand the next caller somebody else's address. +// +// Shallow is enough and deep would be wrong. The pointer fields are the mirrored +// profile and the decoded token, which are read-only facts about the account and +// the credential; auth.Grants holds a map, and its only methods read it. +func copyOf(ac *auth.AuthContext) *auth.AuthContext { + if ac == nil { + return nil + } + c := *ac + return &c +} + +// Allows reports whether a resolved caller's OAuth grants permit acting on scope +// at mode — the gate that complements whatever the service's own access matrix +// decides. A caller must pass both. +// +// scope is the full grant name as meta.sr.ht spells it, service included: +// "cov.sr.ht/REPORTS". The service part is not optional in practice even though +// core-go will fill it in from the ambient config when it is missing, because +// what it fills in is the *calling* service's name read off a context — which is +// right in a service talking about itself and silently wrong everywhere else, +// including in a test. Spell it out. +// +// mode is auth.RO or auth.RW; core-go panics on anything else. +// +// A caller carrying no OAuth grants at all passes unconditionally, and that is +// not a hole in either of the two ways it happens: +// +// - A cookie session, an anonymous request, or a tokens.sr.ht working token +// resolved by the other plane has no BearerToken. It was never scoped by +// meta's vocabulary and cannot be judged in it; a working token is scoped by +// its own grants, asked for separately. +// - A personal access token minted with no grants selected is universal by +// core-go's definition (auth.Grants.HasAll), exactly as it is for every +// upstream service on the instance. +func Allows(ac *auth.AuthContext, scope, mode string) bool { + if ac == nil || ac.BearerToken == nil { + return true + } + return ac.Grants.Has(scope, mode) +} + +// Scope assembles the grant name of one scope on one service — Scope("cov.sr.ht", +// "REPORTS") is "cov.sr.ht/REPORTS". +// +// It exists so that the two spellings a service must keep in agreement are built +// from the same halves: the scope it publishes in api-meta.json, which meta.sr.ht +// turns into a checkbox by prefixing the service name itself, and the grant name +// it checks here. A service should assert them equal in a test rather than hope. +func Scope(service, scope string) string { + return service + "/" + scope +} + +// ScopeName returns the bare scope of a full grant name — the half a service +// publishes in api-meta.json. ScopeName("cov.sr.ht/REPORTS") is "REPORTS". +// +// A name with no service prefix is returned unchanged, which is what makes this +// safe to apply to a value that may already be bare. +func ScopeName(scope string) string { + if _, after, ok := strings.Cut(scope, "/"); ok { + return after + } + return scope +} diff --git a/metapat/metapat_test.go b/metapat/metapat_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4e9791ea2d00c5a9a32ebd47a08eca440e8526bb --- /dev/null +++ b/metapat/metapat_test.go @@ -0,0 +1,630 @@ +package metapat + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "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" + + "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" +) + +// TestMain initialises the process-global core-go crypto state this package +// depends on and never sets up itself. +// +// [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. +// The value is the one core-go's own tests use. +// +// The std logger is silenced because auth.DecodeBearerToken narrates every +// refusal through it, and the refusals are half of what this file tests: without +// this, one `go test` prints a page of "Invalid bearer token" for tokens that +// were invalid on purpose. +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()) + log.SetOutput(io.Discard) + m.Run() +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ( + // metaClientID is what meta.sr.ht stamps into a personal access token: the + // UUID of the OAuth client it was minted for. Its only load-bearing property + // here is that it is not bearer.TokensClientID. + metaClientID = "b2a5e8b0-0c8f-4e4a-9a34-1f9f6b3a0c11" + + covService = "cov.sr.ht" + covReports = covService + "/REPORTS" +) + +// seal mints a token the way the instance's daemons do, 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() +} + +// pat is a live personal access token from meta.sr.ht. +func pat(grantString string) string { + return seal("bigbes", metaClientID, grantString, time.Now().Add(time.Hour)) +} + +// workingToken is a live token from tokens.sr.ht — the other plane. +func workingToken() string { + return seal("bigbes", bearer.TokensClientID, "cov:read", time.Now().Add(time.Hour)) +} + +// backend is a stand-in for meta.sr.ht. It records every call so a test can +// assert not only what came back but whether anything was asked at all — which, +// for the cache, is the whole question. +type backend struct { + mu sync.Mutex + + // userID is filled into every looked-up profile. Zero is the "meta answered + // without an id" case, which is a refusal and not a lookup failure. + userID int + // lookupErr and revokedErr make the two network steps fail. + lookupErr error + revokedErr error + // revoked is what IsRevoked answers when it does not fail. + revoked bool + + lookups int + revChecks int + names []string + clientIDs []string +} + +func (b *backend) LookupUser(_ context.Context, username string, out *auth.AuthContext) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.lookups++ + b.names = append(b.names, username) + if b.lookupErr != nil { + return b.lookupErr + } + out.UserID = b.userID + out.Username = username + out.Email = username + "@example.org" + return nil +} + +func (b *backend) IsRevoked(_ context.Context, _ string, _ [64]byte, clientID string) (bool, error) { + b.mu.Lock() + defer b.mu.Unlock() + + b.revChecks++ + b.clientIDs = append(b.clientIDs, clientID) + if b.revokedErr != nil { + return false, b.revokedErr + } + return b.revoked, nil +} + +func (b *backend) counts() (lookups, revChecks int) { + b.mu.Lock() + defer b.mu.Unlock() + return b.lookups, b.revChecks +} + +// newValidator builds a validator over a healthy backend that resolves everyone +// to user 42, which is what most of these tests want. +func newValidator(t *testing.T, opts ...func(*Options)) (*Validator, *backend) { + t.Helper() + + b := &backend{userID: 42} + o := Options{Service: covService, Backend: b} + for _, fn := range opts { + fn(&o) + } + v, err := New(o) + require.NoError(t, err) + return v, b +} + +// --------------------------------------------------------------------------- +// PlaneOf +// --------------------------------------------------------------------------- + +func TestPlaneOfSeparatesTheTwoPlanes(t *testing.T) { + assert.Equal(t, PlaneMeta, PlaneOf(pat(""))) + assert.Equal(t, PlaneWorking, PlaneOf(workingToken())) +} + +func TestPlaneOfRefusesWhatItCannotRead(t *testing.T) { + // A credential this process cannot decode is PlaneUnknown whoever sealed it: + // the routing question has no answer, and the service owes it a 401 rather + // than a trip to either daemon. + assert.Equal(t, PlaneUnknown, PlaneOf("")) + assert.Equal(t, PlaneUnknown, PlaneOf("not a token at all")) + assert.Equal(t, PlaneUnknown, PlaneOf("!!!not even base64!!!")) +} + +func TestPlaneOfReadsAnExpiredTokenAsUnknown(t *testing.T) { + // auth.DecodeBearerToken checks expiry before it reports anything, so an + // expired PAT never reaches PlaneMeta. The service must answer it like any + // other unreadable credential — the point of documenting this on PlaneOf is + // that "unknown" is tempting to read as "no credential presented". + expired := seal("bigbes", metaClientID, "", time.Now().Add(-time.Hour)) + assert.Equal(t, PlaneUnknown, PlaneOf(expired)) +} + +func TestPlaneNamesItself(t *testing.T) { + assert.Equal(t, "meta.sr.ht personal access token", PlaneMeta.String()) + assert.Equal(t, "tokens.sr.ht working token", PlaneWorking.String()) + assert.Equal(t, "unrecognised credential", PlaneUnknown.String()) + assert.Equal(t, "unrecognised credential", Plane(99).String()) +} + +// --------------------------------------------------------------------------- +// New +// --------------------------------------------------------------------------- + +func TestNewFillsInTheProductionDefaults(t *testing.T) { + v, err := New(Options{Service: covService}) + require.NoError(t, err) + + assert.NotNil(t, v.backend, "a nil Backend must become the core one") + assert.Equal(t, DefaultCacheTTL, v.ttl) + assert.NotNil(t, v.now) + assert.NotNil(t, v.cache) +} + +func TestNewRefusesANegativeTTL(t *testing.T) { + // Refused at construction rather than later: a negative TTL expires every + // entry the instant it is written, which is not a cache misbehaving but a + // service quietly asking meta.sr.ht once per request forever. + _, err := New(Options{Service: covService, CacheTTL: -time.Second}) + require.Error(t, err) + assert.Contains(t, err.Error(), "negative") +} + +func TestNewRefusesAnUnnamedService(t *testing.T) { + // Without it, decoding a grant string reads the service name off the ambient + // context and PANICS when nothing put one there. Refusing here turns a crash + // in whichever caller runs outside an HTTP router into a wiring error at + // startup. + _, err := New(Options{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Service is required") +} + +func TestResolveNeedsNoAmbientConfigContext(t *testing.T) { + // The whole reason Options.Service exists. context.Background() carries no + // service name, and core-go's config.ServiceName panics rather than + // answering "" — so a shared package that leaned on the request context + // would take down every caller that is not an HTTP handler. + v, _ := newValidator(t) + + require.NotPanics(t, func() { + ac, err := v.Resolve(context.Background(), pat(covReports)) + require.NoError(t, err) + assert.True(t, Allows(ac, covReports, auth.RO)) + }) +} + +// --------------------------------------------------------------------------- +// Resolve — the happy path +// --------------------------------------------------------------------------- + +func TestResolveProducesAnOAuth2Caller(t *testing.T) { + v, b := newValidator(t) + token := pat(covReports + ":RO") + + ac, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + + // The shape core-go's own OAuth2 middleware produces, because everything + // downstream — Allows included — is written against that shape. + assert.Equal(t, auth.AUTH_OAUTH2, ac.AuthMethod) + assert.Equal(t, 42, ac.UserID) + assert.Equal(t, "bigbes", ac.Username) + require.NotNil(t, ac.BearerToken) + assert.Equal(t, metaClientID, ac.BearerToken.ClientID) + assert.NotEqual(t, [64]byte{}, ac.TokenHash) + assert.True(t, Allows(ac, covReports, auth.RO)) + + // The revocation row is scoped by the token's own client id, not by ours. + assert.Equal(t, []string{metaClientID}, b.clientIDs) + assert.Equal(t, []string{"bigbes"}, b.names) +} + +func TestResolveAcceptsAnUngrantedTokenAsUniversal(t *testing.T) { + // meta.sr.ht mints a personal token with no grants selected, and core-go + // reads that as every permission. Refusing it here would refuse the most + // common credential on the instance. + v, _ := newValidator(t) + + ac, err := v.Resolve(context.Background(), pat("")) + require.NoError(t, err) + assert.True(t, ac.Grants.HasAll()) + assert.True(t, Allows(ac, covReports, auth.RW)) +} + +// --------------------------------------------------------------------------- +// Resolve — the refusals +// --------------------------------------------------------------------------- + +func TestResolveRefusesAnEmptyCredential(t *testing.T) { + v, b := newValidator(t) + + _, err := v.Resolve(context.Background(), "") + require.ErrorIs(t, err, ErrInvalid) + + lookups, revChecks := b.counts() + assert.Zero(t, lookups) + assert.Zero(t, revChecks) +} + +func TestResolveRefusesAForgedCredentialWithoutAskingMeta(t *testing.T) { + // The ordering is the property: everything that can refuse locally runs + // before anything that touches the network, so a flood of junk tokens does + // not become a flood of requests against meta.sr.ht. + v, b := newValidator(t) + + _, err := v.Resolve(context.Background(), "this is not a bearer token") + require.ErrorIs(t, err, ErrInvalid) + + lookups, revChecks := b.counts() + assert.Zero(t, lookups) + assert.Zero(t, revChecks) +} + +func TestResolveRefusesAnExpiredToken(t *testing.T) { + v, b := newValidator(t) + expired := seal("bigbes", metaClientID, "", time.Now().Add(-time.Hour)) + + _, err := v.Resolve(context.Background(), expired) + require.ErrorIs(t, err, ErrInvalid) + + lookups, _ := b.counts() + assert.Zero(t, lookups) +} + +func TestResolveSendsAWorkingTokenBackToTheOtherPlane(t *testing.T) { + // Not a refusal on the instance's behalf: the token is perfectly good, it + // simply cannot be checked here — its grants are in another vocabulary and + // its revocation row is at another daemon. + v, b := newValidator(t) + + ac, err := v.Resolve(context.Background(), workingToken()) + require.ErrorIs(t, err, ErrNotOurs) + assert.Nil(t, ac, "a caller must not be handed a context it cannot use") + + lookups, _ := b.counts() + assert.Zero(t, lookups, "the other plane's token must not cost a meta lookup") +} + +func TestResolveTreatsALookupFailureAsTransient(t *testing.T) { + // The 503 that has to be defended: "I could not check" is not "your + // credential is bad", and answering 401 here tells every client on the + // instance to re-mint credentials that were never broken. + v, b := newValidator(t) + b.lookupErr = errors.New("dial tcp: connection refused") + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrUnavailable) + assert.NotErrorIs(t, err, ErrInvalid) +} + +func TestResolveRefusesAProfileWithNoID(t *testing.T) { + // meta answered, and answered uselessly. Permanent rather than transient: + // retrying will not conjure the account back, and a zero id downstream + // matches whichever row has an unset owner. + v, b := newValidator(t) + b.userID = 0 + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrInvalid) + assert.NotErrorIs(t, err, ErrUnavailable) + + _, revChecks := b.counts() + assert.Zero(t, revChecks, "a caller with no id is refused before the revocation check") +} + +func TestResolveTreatsARevocationOutageAsTransient(t *testing.T) { + v, b := newValidator(t) + b.revokedErr = errors.New("meta.sr.ht: 502") + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrUnavailable) +} + +func TestResolveRefusesARevokedToken(t *testing.T) { + v, b := newValidator(t) + b.revoked = true + + _, err := v.Resolve(context.Background(), pat("")) + require.ErrorIs(t, err, ErrRevoked) + // 401 and not 403: the token is no longer a credential at all, and a client + // shown 403 keeps presenting it. + assert.NotErrorIs(t, err, ErrForbidden) +} + +func TestResolveRefusesAMalformedGrantString(t *testing.T) { + // core-go's grant grammar is "/[:]". A token whose + // grant string does not parse is not one this instance can reason about. + v, _ := newValidator(t) + + _, err := v.Resolve(context.Background(), pat("garbage-without-a-slash")) + require.ErrorIs(t, err, ErrInvalid) +} + +func TestResolveDoesNotCacheARefusal(t *testing.T) { + // A refusal costs one local HMAC to reproduce; caching it would hand an + // attacker a data structure to grow by presenting garbage, and would pin a + // token to failure across a meta outage that has since ended. + v, b := newValidator(t) + b.lookupErr = errors.New("down") + token := pat("") + + _, err := v.Resolve(context.Background(), token) + require.ErrorIs(t, err, ErrUnavailable) + + b.mu.Lock() + b.lookupErr = nil + b.mu.Unlock() + + ac, err := v.Resolve(context.Background(), token) + require.NoError(t, err, "recovery must not wait out a cached failure") + assert.Equal(t, 42, ac.UserID) +} + +// --------------------------------------------------------------------------- +// The cache +// --------------------------------------------------------------------------- + +func TestResolveAsksMetaOncePerTokenPerTTL(t *testing.T) { + // The reason the cache exists: one federated query fans out across a service's + // resolvers, and each of them would otherwise be a pair of lookups. + v, b := newValidator(t) + token := pat(covReports) + + for range 5 { + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + } + + lookups, revChecks := b.counts() + assert.Equal(t, 1, lookups) + assert.Equal(t, 1, revChecks) +} + +func TestTheCacheExpires(t *testing.T) { + now := time.Now() + clock := func() time.Time { return now } + v, b := newValidator(t, func(o *Options) { + o.CacheTTL = time.Minute + o.Now = clock + }) + token := pat("") + + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + + now = now.Add(time.Minute) // exactly at the boundary: no longer reusable + _, err = v.Resolve(context.Background(), token) + require.NoError(t, err) + + lookups, _ := b.counts() + assert.Equal(t, 2, lookups, "an expired entry must be re-resolved") +} + +func TestDistinctTokensDoNotShareAnEntry(t *testing.T) { + v, b := newValidator(t) + + _, err := v.Resolve(context.Background(), pat(covReports)) + require.NoError(t, err) + _, err = v.Resolve(context.Background(), pat("bench.sr.ht/RESULTS")) + require.NoError(t, err) + + lookups, _ := b.counts() + assert.Equal(t, 2, lookups) +} + +func TestForgetDropsACachedResolution(t *testing.T) { + // For the service that learns out of band that a credential has changed and + // would otherwise keep honouring it for the rest of the TTL. + v, b := newValidator(t) + token := pat("") + + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + v.Forget(token) + _, err = v.Resolve(context.Background(), token) + require.NoError(t, err) + + lookups, _ := b.counts() + assert.Equal(t, 2, lookups) +} + +func TestForgettingAnUncachedTokenIsHarmless(t *testing.T) { + v, _ := newValidator(t) + assert.NotPanics(t, func() { v.Forget("never seen") }) +} + +func TestACallerCannotWriteThroughIntoTheCache(t *testing.T) { + // core-go's own middleware annotates the context it is handed — IPAddress is + // per-request — so handing out the cached pointer would give the next caller + // somebody else's address. + v, _ := newValidator(t) + token := pat("") + + first, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + first.IPAddress = "203.0.113.7" + + second, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + assert.Empty(t, second.IPAddress) + assert.NotSame(t, first, second) +} + +func TestCopyOfPassesNilThrough(t *testing.T) { + // cached() and remember() both call it, and a nil there would be a bug + // elsewhere; passing it through rather than dereferencing keeps that bug + // reported where it happens instead of here. + assert.Nil(t, copyOf(nil)) +} + +func TestTheCacheIsBounded(t *testing.T) { + // Not a tuning knob: reaching the bound means a caller minting a token per + // request, and the answer is to drop everything rather than to spend the + // request budget evicting. + v, _ := newValidator(t) + + for i := range maxCacheEntries + 1 { + token := seal(fmt.Sprintf("user%d", i), metaClientID, "", time.Now().Add(time.Hour)) + _, err := v.Resolve(context.Background(), token) + require.NoError(t, err) + } + + v.mu.Lock() + defer v.mu.Unlock() + assert.LessOrEqual(t, len(v.cache), maxCacheEntries) + assert.NotEmpty(t, v.cache, "the entry that hit the bound is still cached") +} + +func TestResolveIsSafeUnderConcurrency(t *testing.T) { + v, _ := newValidator(t) + token := pat(covReports) + + var wg sync.WaitGroup + for range 32 { + wg.Add(1) + go func() { + defer wg.Done() + ac, err := v.Resolve(context.Background(), token) + assert.NoError(t, err) + assert.NotNil(t, ac) + }() + } + wg.Wait() +} + +// --------------------------------------------------------------------------- +// Allows +// --------------------------------------------------------------------------- + +func TestAllowsHonoursTheScope(t *testing.T) { + v, _ := newValidator(t) + + ac, err := v.Resolve(context.Background(), pat(covReports+":RO")) + require.NoError(t, err) + + assert.True(t, Allows(ac, covReports, auth.RO)) + assert.False(t, Allows(ac, covReports, auth.RW), "a read grant is not a write grant") + assert.False(t, Allows(ac, "bench.sr.ht/RESULTS", auth.RO), "another service's scope is not this one") +} + +func TestAllowsPassesACallerWithNoOAuthGrants(t *testing.T) { + // A cookie session, an anonymous request, or a working token resolved by the + // other plane. None of them was ever scoped in meta's vocabulary, so there + // is nothing here to judge; what they may see is the service's own matrix. + assert.True(t, Allows(nil, covReports, auth.RO)) + assert.True(t, Allows(&auth.AuthContext{}, covReports, auth.RO)) + assert.True(t, Allows(&auth.AuthContext{AuthMethod: auth.AUTH_COOKIE}, covReports, auth.RW)) +} + +func TestAllowsAcceptsAWriteGrantForARead(t *testing.T) { + v, _ := newValidator(t) + + ac, err := v.Resolve(context.Background(), pat(covReports+":RW")) + require.NoError(t, err) + assert.True(t, Allows(ac, covReports, auth.RO)) + assert.True(t, Allows(ac, covReports, auth.RW)) +} + +// --------------------------------------------------------------------------- +// Scope spelling +// --------------------------------------------------------------------------- + +func TestScopeAndScopeNameAreInverses(t *testing.T) { + // The two spellings a service has to keep in agreement: what it publishes in + // api-meta.json, which meta.sr.ht prefixes with the service name itself, and + // the full grant name it checks against. + full := Scope("cov.sr.ht", "REPORTS") + assert.Equal(t, covReports, full) + assert.Equal(t, "REPORTS", ScopeName(full)) +} + +func TestScopeNameLeavesABareScopeAlone(t *testing.T) { + // Which is what makes it safe to apply to a value that may already be bare. + assert.Equal(t, "REPORTS", ScopeName("REPORTS")) +} + +func TestScopeNameTakesTheFirstSlashAsTheSeparator(t *testing.T) { + assert.Equal(t, "a/b", ScopeName("svc.sr.ht/a/b")) +} + +// TestTheDocumentedUsageCompiles pins the routing shape the package comment +// prescribes, so that a change to PlaneOf or Resolve which breaks it fails here +// rather than in four services. +func TestTheDocumentedUsageCompiles(t *testing.T) { + v, _ := newValidator(t) + + resolve := func(presented string) (string, error) { + switch PlaneOf(presented) { + case PlaneMeta: + ac, err := v.Resolve(context.Background(), presented) + if err != nil { + return "", err + } + if !Allows(ac, covReports, auth.RO) { + return "", fmt.Errorf("%w: %s", ErrForbidden, covReports) + } + return ac.Username, nil + case PlaneWorking: + return "working", nil + default: + return "", ErrInvalid + } + } + + who, err := resolve(pat(covReports)) + require.NoError(t, err) + assert.Equal(t, "bigbes", who) + + who, err = resolve(workingToken()) + require.NoError(t, err) + assert.Equal(t, "working", who) + + _, err = resolve(pat("meta.sr.ht/PROFILE:RO")) + require.ErrorIs(t, err, ErrForbidden) + assert.True(t, strings.Contains(err.Error(), covReports)) + + _, err = resolve("nonsense") + require.ErrorIs(t, err, ErrInvalid) +}