package authn
import (
"context"
"errors"
"fmt"
"strings"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-ecore/metapat"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// ScopeRead is the OAuth grant a meta.sr.ht personal access token must carry to
// read through this service: "spec.sr.ht/SPECS", at :RO or better.
//
// It is meta.sr.ht's vocabulary and it is not ActionRead spelled differently.
// The two grammars share a token format and nothing else: ActionRead
// ("spec:read") is what tokens.sr.ht seals into a working token, and no PAT can
// carry it because meta's personal-token page cannot spell it; ScopeRead is a
// checkbox on that page, and no working token can carry it because ecore's
// grants parser does not read that shape at all. A credential has one or the
// other and never both, which is why the two planes are asked different
// questions about the same request (Principal.Authorize, metapat.Allows).
//
// The service half is ConfigSection, so a rename breaks the build here rather
// than leaving behind a scope nobody can be granted. The bare half — "SPECS" —
// is what api-meta.json publishes, because meta prefixes the service name
// itself; cmd/specsrht derives that list from this constant rather than spelling
// it a second time, and a test on each side asserts the two agree. A scope
// published and not checked admits what should have been refused; one checked
// and not published cannot be minted at all; neither failure is visible from
// inside a single file.
const ScopeRead = ConfigSection + "/SPECS"
// MetaValidator is the sliver of sr-ht-ecore's metapat.Validator this plane
// needs.
//
// The interface is declared in the consumer, as BearerValidator and UserLookup
// are and for the same reasons: it states exactly how much of the shared
// validator this package depends on — one method — and it is what keeps authn
// testable with no meta.sr.ht, no network and no Postgres. The concrete
// validator reaches core-go's auth.LookupUser, which reads a database handle out
// of the context and panics without one; holding it behind an interface is what
// keeps that dependency in cmd/ where the wiring lives, exactly as this package's
// doc comment promises.
type MetaValidator interface {
// Resolve verifies the signature and expiry locally, refuses a token sealed
// by tokens.sr.ht, mirrors the owner's profile and asks meta.sr.ht whether
// the token has been revoked. It answers with core-go's own OAuth2 caller, or
// with one of the metapat package's sentinels.
Resolve(ctx context.Context, presented string) (*auth.AuthContext, error)
}
// Compile-time proof that the shared validator satisfies the port. It is what
// lets this package depend on the interface rather than on *metapat.Validator,
// and it fails the build the moment either side drifts.
var _ MetaValidator = (*metapat.Validator)(nil)
// MetaAuth is the meta.sr.ht plane: an ordinary personal access token, the
// credential every upstream service on this instance already accepts.
//
// # Why spec.sr.ht accepts one at all
//
// It did not, and the reason it does now is the gateway. api.sr.ht forwards ONE
// client "Authorization" header to every service a federated query touches — its
// AuthMiddleware copies the client's header verbatim into the request context,
// and the internal credential it can mint is used only to fetch schemas at
// startup — so a federated caller arrives holding whatever credential the client
// had. The only credential that works across the whole instance is a meta PAT,
// so an endpoint that refuses them answers 401 to the first authenticated
// federated query and can never be part of the gateway's schema, however correct
// each individual refusal looks.
//
// # Where it is and is not accepted
//
// This plane is deliberately NOT part of Resolver. It is reachable from /query
// alone, which is the only surface with the federation problem; the REST write
// plane, /mcp and the push hook keep asking for a tokens.sr.ht working token,
// where a narrow, short-lived, revocable grant is worth what it costs an agent to
// obtain. Nothing on those paths holds a MetaAuth, so nothing on them can produce
// a PlaneMeta principal, and a PAT is not a way around a grant.
//
// # What it is left deciding
//
// It owns no crypto and no lookups: verification is ecore's metapat, one copy of
// that check for every service on the instance. Three questions are this
// service's own and are why the type exists at all — whether the token's owner is
// the one human this instance answers to, whether it carries the scope, and which
// failures of the shared validator are a bad credential, which are a missing
// permission, and which are an outage.
type MetaAuth struct {
validator MetaValidator
owner string
scope string
}
// NewMetaAuth builds the plane over a validator, the instance owner username
// from [sr.ht] owner-name, and the OAuth scope a token must carry to read
// through this service.
//
// Each argument is refused rather than tolerated when it is empty, and the three
// reasons are different:
//
// - A nil validator is not the "this instance has no such plane" configuration.
// That one is a nil *MetaAuth, which graph.New refuses outright because the
// plane needs no per-instance origin and so can never be legitimately absent.
// A validator-less plane would fail every PAT while looking configured.
// - An empty owner would compare every token's account against "", so either no
// PAT would ever be admitted, or — worse, if the comparison were ever loosened
// — the check that makes this a single-owner instance would be the one that
// silently did nothing. NewResolver validates its owner for the same reason
// and with the same rule, so the two planes cannot disagree about who bigbes
// is.
// - An empty scope would have metapat.Allows asked about the grant name "",
// which no token carries and no meta checkbox can mint, so every PAT on the
// instance would be refused with a message naming a permission that does not
// exist.
func NewMetaAuth(validator MetaValidator, owner, scope string) (*MetaAuth, error) {
if validator == nil {
return nil, fmt.Errorf("authn: nil MetaValidator")
}
owner = strings.TrimPrefix(owner, "~")
if err := core.ValidateOwner(owner); err != nil {
return nil, fmt.Errorf("authn: meta plane instance owner: %w", err)
}
if scope == "" {
return nil, fmt.Errorf("authn: empty OAuth scope, e.g. %s", ScopeRead)
}
return &MetaAuth{validator: validator, owner: owner, scope: scope}, nil
}
// Scope is the OAuth grant this plane requires, for a caller that wants to name
// it in a refusal without spelling it a second time.
func (a *MetaAuth) Scope() string { return a.scope }
// Owner is the instance owner username this plane admits tokens for.
func (a *MetaAuth) Owner() string { return a.owner }
// VerifyToken validates a presented personal access token and returns the
// principal of its owner.
//
// presented is the bare credential, with the "Bearer " scheme already stripped.
//
// The principal is KindAgent, never KindOwner, even though the token belongs to
// the instance owner's own meta.sr.ht account. That is the security decision of
// this whole change: KindOwner may approve proposals and manage webhooks, and it
// is reached by a unified-login cookie — a browser session a human is sitting in
// front of — while a PAT is a bearer string any process holding it can present,
// forwarded through a gateway by whatever client asked. Reading one as the owner
// would hand the approved branch to the widest credential on the instance.
//
// It carries no Grants either: those are tokens.sr.ht's vocabulary and a PAT is
// in meta's, which is checked here instead — once, against the scope this plane
// was built with, because the whole surface it guards is a read. That is also why
// Principal.Authorize passes a PlaneMeta caller through: asking it for a grant a
// PAT can never carry would refuse every one of them.
//
// Agent identity and session are left empty, and this plane is given the
// credential rather than the request precisely so that they cannot be otherwise.
// Provenance is demanded at a write (AgentWrite.Validate) and /query performs
// none — its only mutations are the webhook ones, which webhookAuthorized
// restricts to KindOwner — so there is nothing here to attribute. A plane handed
// the request could also read a cookie off it, which would quietly reintroduce
// the ambient authority this endpoint exists without.
//
// The failures are this package's sentinels, so that graph turns them into
// statuses through the same StatusFor table it already uses for the other plane.
func (a *MetaAuth) VerifyToken(ctx context.Context, presented string) (Principal, error) {
if presented == "" {
return Anonymous(), ErrNoToken
}
ac, err := a.validator.Resolve(ctx, presented)
if err != nil {
return Anonymous(), classifyResolve(err)
}
if ac.UserID == 0 {
// metapat refuses this itself; the check is kept because the seam is an
// interface, and a validator that answered with an empty context must not
// produce a principal that owns whichever row has an unset owner id.
return Anonymous(), fmt.Errorf(
"%w: it resolved to no meta.sr.ht user id", ErrInvalidPersonalToken)
}
// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
// means anything. This is the rule resolveInstanceToken applies to a working
// token and the cookie plane applies to a session, and it matters most here:
// a PAT is the credential every account on the instance can mint for itself,
// so without this check the widest credential in existence would be the one
// that skipped the narrowest identity rule, and any user of the instance could
// read the whole corpus through /query.
//
// It is a refusal rather than a downgrade to anonymous because a presented
// credential that fails must fail at the door: the asymmetry this package's
// doc comment draws between cookies and bearer tokens.
username := strings.TrimPrefix(ac.Username, "~")
if username != a.owner {
return Anonymous(), fmt.Errorf(
"%w: the personal access token belongs to ~%s, and this instance answers only to ~%s",
ErrNotInstanceOwner, username, a.owner)
}
if !metapat.Allows(ac, a.scope, auth.RO) {
return Anonymous(), fmt.Errorf(
"%w: this personal access token does not carry %s", ErrMissingScope, a.scope)
}
return Principal{
Kind: KindAgent,
Owner: a.owner,
// Diagnostics only, as on the other plane. There is no row id to print:
// meta.sr.ht does not number a PAT the way tokens.sr.ht numbers a working
// token, and naming the plane is the useful half anyway — it says which of
// two credentials a log line is about.
TokenName: "meta.sr.ht personal access token",
Plane: PlaneMeta,
UserID: ac.UserID,
}, nil
}
// classifyResolve maps metapat's sentinels onto this package's, and is the one
// place spec.sr.ht decides what each refusal of the shared PAT validator means
// here.
//
// It is deliberately the same shape as the tokens.sr.ht plane's mapping, arm for
// arm — resolveInstanceToken wraps bearer's sentinels and StatusFor reads them —
// because the two planes owe the surfaces above the same three answers, 401, 403
// and 503. A difference between the tables would be a difference in what a client
// is told about the same kind of failure, decided by which credential it happened
// to be holding.
//
// metapat.ErrNotOurs is classified for totality and is not reachable in
// production: graph routes on metapat.PlaneOf before this plane is asked, so a
// working token has already gone to the other one. Were it ever to arrive here it
// is a bad credential *for this plane*, and 401 is the honest answer.
//
// An unrecognised error is ErrMetaUnavailable, and that is the fail-closed
// direction rather than a shrug: a sentinel this table has never seen must read
// as "I could not decide" — a 503 the caller retries — and never as a verdict
// about the credential. Answering 401 to something this function does not
// understand would tell a client to re-mint a token that may be perfectly good.
func classifyResolve(err error) error {
switch {
case errors.Is(err, metapat.ErrInvalid),
errors.Is(err, metapat.ErrNotOurs),
errors.Is(err, metapat.ErrRevoked):
return fmt.Errorf("%w: %w", ErrInvalidPersonalToken, err)
case errors.Is(err, metapat.ErrForbidden):
// Not reachable through Resolve, which is never told a scope; classified
// so that the table is total.
return fmt.Errorf("%w: %w", ErrMissingScope, err)
case errors.Is(err, metapat.ErrUnavailable):
// meta.sr.ht and not tokens.sr.ht, which is the entire reason this
// sentinel exists beside bearer.ErrUnavailable rather than reusing it.
return fmt.Errorf("%w: %w", ErrMetaUnavailable, err)
default:
return fmt.Errorf("%w: validating a meta.sr.ht personal access token: %w",
ErrMetaUnavailable, err)
}
}