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