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