package authn
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
)
// The grant vocabulary spec.sr.ht declares for tokens.sr.ht working tokens.
//
// The daemon that mints them does not know these strings and must not: the
// tokens.sr.ht spec gives the vocabulary to the services, so that adding an
// action to spec.sr.ht is a change to spec.sr.ht. An unknown grant simply
// admits nobody. They are constants rather than literals at the check because a
// grant is compared byte for byte — a typo in one of the two places it is
// spelled is a silent widening or a silent refusal, and neither shows up until
// it matters.
const (
// ActionPropose is what an instance token must carry to write: open a
// proposal or add documents to one.
ActionPropose = "spec:propose"
// ActionRead is what an instance token must carry to read content through
// any of the read surfaces (the web UI, /query, MCP).
ActionRead = "spec:read"
)
// BearerValidator is the sliver of sr-ht-ecore's bearer.Validator this package
// needs: who a presented working token belongs to, what it permits, and whether
// it is still live.
//
// Inspect and not Validate, because the resolver runs in middleware upstream of
// the router and so does not know which action is being attempted. The grant
// check happens where the action is known — service.Propose for the write
// plane, the read gates for the read plane — through Principal.Authorize.
//
// It is an interface rather than a *bearer.Validator so that this package stays
// testable without a tokens.sr.ht to talk to, exactly as TokenStore keeps it
// testable without a Postgres.
type BearerValidator interface {
Inspect(ctx context.Context, presented string) (*bearer.Token, error)
}
// InstanceUser is the local "user" row that the owner of an instance token
// resolves to. It is the whole of what this package needs from that row: the id
// other layers key user-scoped state by, and the name it was found under.
type InstanceUser struct {
ID int
Username string
}
// UserLookup resolves the meta.sr.ht username an instance token names into this
// service's local user row — core-go's auth.LookupUser in production.
//
// It is declared here for the same reason TokenStore is: that function reads a
// database handle and a config out of the context and panics without either,
// which is service/'s business to supply and not something a package answering
// "who is making this request?" should carry. service/ wires the real one in;
// tests wire a map.
type UserLookup interface {
LookupUser(ctx context.Context, username string) (InstanceUser, error)
}
// resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer
// credential. Its answer is final: there is one agent credential plane, so a
// refusal here is the service's refusal.
//
// It used to report a third thing — whether the caller should fall back to
// spec's own agent_token store — and exactly two refusals said yes:
//
// - bearer.ErrInvalid, because spec's local token was 32 random bytes in
// base64, which is precisely what "did not decode as one of ours" looks
// like;
// - bearer.ErrNotOurs, because refusing a meta.sr.ht PAT was the local plane's
// business rather than this one's, and falling through cost one hash lookup
// that would miss.
//
// With that store gone both are plain refusals. The one consequence worth
// naming is ErrNotOurs: IsAuthFailure now counts it permanent, so a meta PAT
// presented here earns a 401 rather than the 503 an unclassified error would.
//
// The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and
// ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and
// ErrUnavailable is 503 — never 401, because "I could not ask tokens.sr.ht" is
// not "your token is bad".
func (rs *Resolver) resolveInstanceToken(
ctx context.Context, presented, agent, session string,
) (Principal, error) {
tok, err := rs.bearer.Inspect(ctx, presented)
if err != nil {
return Anonymous(), fmt.Errorf("authn: instance token: %w", err)
}
// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
// means anything. This is the same rule the cookie plane already applies —
// a real user who is not the instance owner reads as nobody — and applying
// it here keeps every consumer of Principal.Owner honest: the provenance
// committer, the refs rule's principal kind and coreauth's AuthContext all
// assume the human an agent acts for is the instance owner, and a foreign
// name would make each of them quietly wrong in a different way.
//
// 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(tok.Username, "~")
if username != rs.owner {
return Anonymous(), fmt.Errorf(
"%w: the token belongs to ~%s, and this instance answers only to ~%s",
ErrNotInstanceOwner, username, rs.owner)
}
// The owner is resolved to a local row even though single-user spec could
// infer it: the row id is what user-scoped state keys off, and looking it up
// here is what makes the instance plane's identity a fact about this
// database rather than a name copied out of a signed blob.
user, err := rs.users.LookupUser(ctx, username)
if err != nil {
// Unclassified, therefore transient, therefore 503: a database that
// cannot answer must never read as a bad credential.
return Anonymous(), fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err)
}
return Principal{
Kind: KindAgent,
Owner: rs.owner,
Agent: agent,
Session: session,
TokenName: instanceTokenLabel(tok),
Plane: PlaneInstance,
Grants: tok.Grants,
UserID: user.ID,
}, nil
}
// instanceTokenLabel names the credential in a log line. A registered token has
// a row at tokens.sr.ht an operator can find and revoke, so its id is the useful
// thing to print; a stateless one was never written down, and saying so is more
// honest than printing "0".
func instanceTokenLabel(tok *bearer.Token) string {
if tok.Registered() {
return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID)
}
return "tokens.sr.ht (stateless)"
}
// StatusFor maps an error out of Resolve — or out of a later Authorize — onto
// the status the surface must answer with. It is one function so that the three
// surfaces cannot each invent their own table.
//
// Everything the credential itself can be wrong about is bearer.StatusFor's
// answer, not a second copy of it: ErrForbidden is 403, ErrUnavailable is 503
// and never 401, and ErrInvalid, ErrRevoked and ErrNotOurs are 401. That last
// arm is only reached because ErrNotOurs is decided before we ask — a meta.sr.ht
// PAT used to fall through to spec's own token store, and with that store gone
// it is a refusal at the door.
//
// The ErrUnavailable line is the one worth restating even though it is no longer
// spelled here. Reading "I could not reach tokens.sr.ht" as "your token is
// revoked" would refuse every live instance token for as long as a daemon that
// is deliberately off the hot path takes to restart, and would tell a thousand
// clients their credentials are bad when the truth is that one service is down.
//
// What this function adds is what bearer cannot know:
//
// - ErrMissingGrant and ErrNotInstanceOwner are 403. The credential verifies
// and the holder is who they say they are, so retrying is pointless and what
// they need is a wider grant, not another login. Both are asked before the
// bearer table, because ErrMissingGrant is raised beside a token that
// verified and must not be read as one that did not.
// - Whatever else IsAuthFailure calls permanent is 401 — today that is
// ErrNoToken, nothing having been presented on a surface that requires a
// credential. The predicate is asked rather than the sentinel listed a second
// time, so that a sentinel added to one of them cannot be missing from the
// other: this package's two answers to "is the credential the problem?" have
// to agree, and the cheapest way to guarantee that is for one to be built
// from the other.
// - ErrNoAgentPlane, and anything else at all, is 503. An instance with no
// [tokens.sr.ht] origin cannot check any credential, and telling the holder
// of a good token that it is bad would send them to re-provision it; an
// unclassified error is a backend that could not answer. This is where the
// two tables' defaults deliberately differ — bearer's unrecognised failure
// is the caller's credential, because everything reaching it is about a
// credential, while an unrecognised failure here can be the database this
// resolver had to consult, which must never read as a bad token.
func StatusFor(err error) int {
switch {
case err == nil:
return http.StatusOK
case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrNotInstanceOwner):
return http.StatusForbidden
case isBearerRefusal(err):
return bearer.StatusFor(err)
case IsAuthFailure(err):
return http.StatusUnauthorized
default:
return http.StatusServiceUnavailable
}
}
// isBearerRefusal reports whether err is one of the sentinels bearer.StatusFor
// has an answer for. The list is here rather than in a helper over there because
// it is the question "did the shared validator decide this?", and a wrong answer
// to it is what would let the 503 default below swallow a 401 — or, worse, let
// bearer's own 401 default swallow a database outage.
func isBearerRefusal(err error) bool {
return errors.Is(err, bearer.ErrForbidden) ||
errors.Is(err, bearer.ErrUnavailable) ||
errors.Is(err, bearer.ErrInvalid) ||
errors.Is(err, bearer.ErrRevoked) ||
errors.Is(err, bearer.ErrNotOurs)
}
// Challenge is the WWW-Authenticate value every 401 this service answers must
// carry, per RFC 9110 §11.6.1 — the scheme, and this service's config section as
// the realm, which is what names it in the config, in the nav and in a grant
// everywhere else on the instance.
//
// It is bearer.Challenge with our section already in it, so that the four
// surfaces that refuse a credential (the resolver's middleware, MCP, /query and
// the read plane's machine formats) cannot name four realms.
func Challenge() string { return bearer.Challenge(ConfigSection) }