package authn
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-ecore/login"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// cookieDecode is how this service reads the instance's unified-login cookie:
// sr-ht-ecore's one decoder, told to use core.ValidateOwner as its name rule.
//
// The decode itself is not ours and never was — decrypt without expiration,
// unmarshal core-go's claims, strip one leading '~', treat every failure as
// anonymity — and six services each keeping a copy of those four steps is how
// one of them ends up missing the fifth. The fifth is the validator, and it is
// the one thing here that stays spec.sr.ht's: a decoded name goes on to be
// joined into a repository path under [spec.sr.ht] repos, and core.ValidateOwner
// is the rule the rest of this service builds those paths against. Passing it
// means there is one grammar rather than two that agree until one of them is
// widened.
//
// Resolved once, at package level, because it is read on every request and
// login.Option is a build step.
var cookieDecode = []login.Option{login.WithValidator(validCookieName)}
// validCookieName adapts core.ValidateOwner to login's predicate shape. An
// error is "not a usable identity", which login turns into an anonymous viewer
// — never an error page, because a name this service cannot use is the same
// thing to a browser as no cookie at all.
func validCookieName(name string) bool { return core.ValidateOwner(name) == nil }
// Resolver turns a request into a Principal. It holds the instance owner
// username — the one name a cookie has to match to carry authority — and, when
// the instance is configured for it, the tokens.sr.ht plane every agent
// credential is checked against.
type Resolver struct {
owner string
// bearer and users are the agent plane, installed by WithInstancePlane. Both
// are nil when the instance config has no [tokens.sr.ht] section, and a
// resolver in that state authenticates no agent at all: since spec stopped
// minting its own credential there is nothing else for a bearer token to be
// checked against. It is still a legal resolver — the CLI paths that
// authenticate nobody build one — but a bearer credential presented to it is
// a hard ErrNoAgentPlane, never a shrug.
bearer BearerValidator
users UserLookup
}
// ResolverOption configures a Resolver at construction. Options rather than a
// second constructor because the plane is genuinely absent in some processes:
// `specsrht doc` builds a Service, resolves nobody, and has no use for an HTTP
// client to tokens.sr.ht.
type ResolverOption func(*Resolver) error
// WithInstancePlane wires the tokens.sr.ht bearer plane in: v validates a
// presented working token, users resolves its owner to a local row.
//
// Both are required together. A validator with no way to resolve an owner would
// authenticate a token and then have nothing to say about who presented it,
// which is the whole of what an agent credential is for here.
func WithInstancePlane(v BearerValidator, users UserLookup) ResolverOption {
return func(rs *Resolver) error {
if v == nil {
return fmt.Errorf("authn: nil BearerValidator")
}
if users == nil {
return fmt.Errorf("authn: nil UserLookup")
}
rs.bearer = v
rs.users = users
return nil
}
}
// NewResolver builds a Resolver for the instance owner named in
// [sr.ht] owner-name.
//
// Pass WithInstancePlane to give it an agent plane. Without one it resolves
// cookies and refuses every bearer credential with ErrNoAgentPlane; the daemon
// therefore builds one with the plane and fails startup if it cannot, while the
// CLI paths that authenticate nobody build one without.
func NewResolver(owner string, opts ...ResolverOption) (*Resolver, error) {
owner = strings.TrimPrefix(owner, "~")
if err := core.ValidateOwner(owner); err != nil {
return nil, fmt.Errorf("authn: instance owner: %w", err)
}
rs := &Resolver{owner: owner}
for _, opt := range opts {
if err := opt(rs); err != nil {
return nil, err
}
}
return rs, nil
}
// HasInstancePlane reports whether this resolver can authenticate an agent at
// all. Startup logging and tests only; never an authorization input.
func (rs *Resolver) HasInstancePlane() bool { return rs.bearer != nil }
// Owner returns the instance owner username this resolver recognises.
func (rs *Resolver) Owner() string { return rs.owner }
// Resolve determines who is making a request.
//
// A bearer token wins over a cookie when both are present: an agent that went
// to the trouble of presenting a credential is asking to be treated as an
// agent, and letting a stale browser cookie promote it to the owner would hand
// it the approved branch. The two credentials are checked in that order and
// never merged.
//
// A presented bearer token goes to the tokens.sr.ht plane and nowhere else.
// There is no second store behind it since spec stopped minting its own
// credential, so every refusal that plane returns is final — see
// resolveInstanceToken.
//
// The error contract is asymmetric on purpose:
//
// - No bearer token: never an error. The cookie decides between KindOwner and
// KindAnonymous, and any cookie problem is anonymity, not failure.
// - A bearer token that fails: an error. StatusFor separates the 401 case
// (malformed, foreign, revoked) from the 403 case (a good token this
// instance has nothing to grant) and the 503 case (tokens.sr.ht
// unreachable, or no plane wired at all).
//
// The agent identity and session headers are read here but not required: they
// are demanded at the write, by AgentWrite.Validate, which is the only place
// the design requires them and the only place a missing one can do harm.
func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, error) {
if presented := BearerFromRequest(r); presented != "" {
return rs.ResolveAgent(ctx, presented,
r.Header.Get(HeaderAgent), r.Header.Get(HeaderAgentSession))
}
username := login.UsernameFromRequest(r, cookieDecode...)
if username == "" {
return Anonymous(), nil
}
if username != rs.owner {
// A real user of the instance who is not bigbes. Single-user means
// there is nothing to grant them, so they read exactly as an anonymous
// viewer does; the name is kept for the log line and the "you are
// signed in as" affordance only.
return Principal{Kind: KindAnonymous, CookieUser: username}, nil
}
return Principal{Kind: KindOwner, Owner: username, CookieUser: username}, nil
}
// ResolveAgent authenticates a presented agent credential, with the provenance
// the caller collected alongside it, and is what Resolve calls once it has
// pulled all three out of an HTTP request.
//
// It is exported because the push path is not an HTTP request: a `git push`
// arrives over SSH and the credential reaches the daemon in a hook's
// environment, not in an Authorization header. That path used to check the
// agent_token table directly, which is precisely how it ended up unable to
// accept an instance token while the HTTP surfaces could. One credential plane
// deserves one implementation of "is this credential good?", so hooks calls this
// and the two surfaces cannot drift.
//
// It never returns an anonymous principal on failure: a presented credential
// that does not verify is an error, so the caller refuses at the door instead of
// silently downgrading an agent to a reader.
func (rs *Resolver) ResolveAgent(ctx context.Context, presented, agent, session string) (Principal, error) {
if presented == "" {
return Anonymous(), ErrNoToken
}
if rs.bearer == nil {
// Not a bad credential: this process cannot check any credential. 503
// via StatusFor, and the operator's clue is in the message rather than
// in an agent's incident report about a token that "stopped working".
return Anonymous(), fmt.Errorf(
"%w: spec.sr.ht authenticates agents through tokens.sr.ht, and this instance's "+
"config.ini has no [tokens.sr.ht] origin", ErrNoAgentPlane)
}
return rs.resolveInstanceToken(ctx, presented,
strings.TrimSpace(agent), strings.TrimSpace(session))
}
// Middleware attaches the resolved Principal to the request context, where
// PrincipalFromContext reads it.
//
// It rejects only a failed bearer token — 401 for a bad credential, 403 for a
// good one that this instance has nothing to grant, 503 for a backend that could
// not answer, per StatusFor. Everything else, including every cookie problem,
// flows through as anonymous: the read plane is anonymous-capable and must never
// answer an error page on identity grounds.
func (rs *Resolver) Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := rs.Resolve(r.Context(), r)
if err != nil {
status := StatusFor(err)
if status >= 500 {
// Fail closed and loudly. The alternative — degrading to
// anonymous — would turn a Postgres blip or an unreachable
// tokens.sr.ht into agents silently losing their write
// access.
slog.ErrorContext(r.Context(), "resolving an agent credential failed",
"method", r.Method, "path", r.URL.Path, "status", status,
scribe.Err(err))
}
if status == http.StatusUnauthorized {
// RFC 9110 requires the challenge on a 401, and the caller
// here is always a machine holding a bearer token: naming the
// scheme and the realm is what tells it which credential this
// service was refusing.
w.Header().Set("WWW-Authenticate", Challenge())
}
http.Error(w, resolveFailureMessage(status), status)
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
})
}
}
// resolveFailureMessage is what a refused caller is told. It is keyed on the
// status and not on the error, so that nothing about which plane refused, whose
// token it was, or whether a row exists leaks to a caller holding a credential
// this service did not accept.
func resolveFailureMessage(status int) string {
switch status {
case http.StatusUnauthorized:
return "invalid agent token"
case http.StatusForbidden:
return "this token does not authorize requests to spec.sr.ht"
default:
return "authentication backend unavailable"
}
}