@@ 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
+}
@@ 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 "<service>/<scope>[:<mode>]". 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)
+}