// 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 }