package authn import ( "context" "fmt" "log" "net/http" "strings" "sourcecraft.dev/bigbes/sr-ht-spec/core" ) // Resolver turns a request into a Principal. It holds the instance owner // username — the one name a cookie has to match to carry authority — the // TokenStore local agent tokens are checked against, and, when the instance is // configured for it, the tokens.sr.ht plane. type Resolver struct { owner string store TokenStore // bearer and users are the instance plane, installed by WithInstancePlane. // Both are nil when the instance config has no [tokens.sr.ht] section, which // is a supported configuration and not a broken one: the plane is absent and // every bearer credential goes straight to the local store, exactly as it // did before this plane existed. Resolve tests bearer for presence, and // WithInstancePlane is what guarantees the two are wired together or not at // all. bearer BearerValidator users UserLookup } // ResolverOption configures a Resolver at construction. Options rather than a // second constructor because the instance plane is optional in production and // not merely in tests: an instance without tokens.sr.ht must build the same // resolver every other caller does. 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 one thing the instance plane adds over the local one. 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. // // A nil store is rejected rather than tolerated: with no store every agent // token would resolve as unknown, which looks exactly like a mass revocation // and is a miserable thing to debug at 2am. Wire a store or do not build a // resolver. // // With no options the resolver knows only the local agent-token plane — what an // instance with no [tokens.sr.ht] section gets, and what every caller got before // that plane existed. func NewResolver(owner string, store TokenStore, opts ...ResolverOption) (*Resolver, error) { owner = strings.TrimPrefix(owner, "~") if err := core.ValidateOwner(owner); err != nil { return nil, fmt.Errorf("authn: instance owner: %w", err) } if store == nil { return nil, fmt.Errorf("authn: nil TokenStore") } rs := &Resolver{owner: owner, store: store} for _, opt := range opts { if err := opt(rs); err != nil { return nil, err } } return rs, nil } // HasInstancePlane reports whether this resolver tries tokens.sr.ht before the // local agent-token store. 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 is offered to the tokens.sr.ht plane first, when one // is configured, and reaches the local agent-token store only if that plane // says the string is not a token of the instance's. Which refusals mean that, // and why the order is not the other way round, is in 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. IsAuthFailure separates the 401 case // (unknown, revoked, malformed) from the 503 case (store unreachable). // // 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 != "" { if rs.bearer != nil { p, fallBack, err := rs.resolveInstanceToken(ctx, r, presented) if !fallBack { return p, err } } tok, err := ResolveAgentToken(ctx, rs.store, presented) if err != nil { return Anonymous(), err } return Principal{ Kind: KindAgent, Owner: rs.owner, Agent: strings.TrimSpace(r.Header.Get(HeaderAgent)), Session: strings.TrimSpace(r.Header.Get(HeaderAgentSession)), TokenName: tok.Name, Plane: PlaneLocal, }, nil } username := UsernameFromRequest(r) 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 } // 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. log.Printf("authn: resolving bearer credential: %v", err) } 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" } }