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 — and the // TokenStore agents are checked against. type Resolver struct { owner string store TokenStore } // 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. func NewResolver(owner string, store TokenStore) (*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") } return &Resolver{owner: owner, store: store}, 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. // // 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 != "" { 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, }, 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, 503 for a // store that could not answer. 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 { if IsAuthFailure(err) { http.Error(w, "invalid agent token", http.StatusUnauthorized) return } // Fail closed and loudly. The alternative — degrading to // anonymous — would turn a Postgres blip into agents silently // losing their write access. log.Printf("authn: resolving agent token: %v", err) http.Error(w, "authentication backend unavailable", http.StatusServiceUnavailable) return } next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p))) }) } }