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