coverage~bigbes/sr-ht-spec64cae3afauthn/resolver.go

Coverage
100.0% 48/48 statements
Δ
+0.0
Blob
fc5ed47
Uncovered nothing — every instrumented line ran
1 package authn
2
3 import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "strings"
9
10 "go.bigb.es/auxilia/scribe"
11 "sourcecraft.dev/bigbes/sr-ht-ecore/login"
12
13 "sourcecraft.dev/bigbes/sr-ht-spec/core"
14 )
15
16 // cookieDecode is how this service reads the instance's unified-login cookie:
17 // sr-ht-ecore's one decoder, told to use core.ValidateOwner as its name rule.
18 //
19 // The decode itself is not ours and never was — decrypt without expiration,
20 // unmarshal core-go's claims, strip one leading '~', treat every failure as
21 // anonymity — and six services each keeping a copy of those four steps is how
22 // one of them ends up missing the fifth. The fifth is the validator, and it is
23 // the one thing here that stays spec.sr.ht's: a decoded name goes on to be
24 // joined into a repository path under [spec.sr.ht] repos, and core.ValidateOwner
25 // is the rule the rest of this service builds those paths against. Passing it
26 // means there is one grammar rather than two that agree until one of them is
27 // widened.
28 //
29 // Resolved once, at package level, because it is read on every request and
30 // login.Option is a build step.
31 var cookieDecode = []login.Option{login.WithValidator(validCookieName)}
32
33 // validCookieName adapts core.ValidateOwner to login's predicate shape. An
34 // error is "not a usable identity", which login turns into an anonymous viewer
35 // — never an error page, because a name this service cannot use is the same
36 // thing to a browser as no cookie at all.
37 10 func validCookieName(name string) bool { return core.ValidateOwner(name) == nil }
38
39 // Resolver turns a request into a Principal. It holds the instance owner
40 // username — the one name a cookie has to match to carry authority — and, when
41 // the instance is configured for it, the tokens.sr.ht plane every agent
42 // credential is checked against.
43 type Resolver struct {
44 owner string
45
46 // bearer and users are the agent plane, installed by WithInstancePlane. Both
47 // are nil when the instance config has no [tokens.sr.ht] section, and a
48 // resolver in that state authenticates no agent at all: since spec stopped
49 // minting its own credential there is nothing else for a bearer token to be
50 // checked against. It is still a legal resolver — the CLI paths that
51 // authenticate nobody build one — but a bearer credential presented to it is
52 // a hard ErrNoAgentPlane, never a shrug.
53 bearer BearerValidator
54 users UserLookup
55 }
56
57 // ResolverOption configures a Resolver at construction. Options rather than a
58 // second constructor because the plane is genuinely absent in some processes:
59 // `specsrht doc` builds a Service, resolves nobody, and has no use for an HTTP
60 // client to tokens.sr.ht.
61 type ResolverOption func(*Resolver) error
62
63 // WithInstancePlane wires the tokens.sr.ht bearer plane in: v validates a
64 // presented working token, users resolves its owner to a local row.
65 //
66 // Both are required together. A validator with no way to resolve an owner would
67 // authenticate a token and then have nothing to say about who presented it,
68 // which is the whole of what an agent credential is for here.
69 26 func WithInstancePlane(v BearerValidator, users UserLookup) ResolverOption {
70 26 return func(rs *Resolver) error {
71 26 if v == nil {
72 1 return fmt.Errorf("authn: nil BearerValidator")
73 1 }
74 25 if users == nil {
75 1 return fmt.Errorf("authn: nil UserLookup")
76 1 }
77 24 rs.bearer = v
78 24 rs.users = users
79 24 return nil
80 }
81 }
82
83 // NewResolver builds a Resolver for the instance owner named in
84 // [sr.ht] owner-name.
85 //
86 // Pass WithInstancePlane to give it an agent plane. Without one it resolves
87 // cookies and refuses every bearer credential with ErrNoAgentPlane; the daemon
88 // therefore builds one with the plane and fails startup if it cannot, while the
89 // CLI paths that authenticate nobody build one without.
90 30 func NewResolver(owner string, opts ...ResolverOption) (*Resolver, error) {
91 30 owner = strings.TrimPrefix(owner, "~")
92 30 if err := core.ValidateOwner(owner); err != nil {
93 2 return nil, fmt.Errorf("authn: instance owner: %w", err)
94 2 }
95 28 rs := &Resolver{owner: owner}
96 28 for _, opt := range opts {
97 26 if err := opt(rs); err != nil {
98 2 return nil, err
99 2 }
100 }
101 26 return rs, nil
102 }
103
104 // HasInstancePlane reports whether this resolver can authenticate an agent at
105 // all. Startup logging and tests only; never an authorization input.
106 1 func (rs *Resolver) HasInstancePlane() bool { return rs.bearer != nil }
107
108 // Owner returns the instance owner username this resolver recognises.
109 1 func (rs *Resolver) Owner() string { return rs.owner }
110
111 // Resolve determines who is making a request.
112 //
113 // A bearer token wins over a cookie when both are present: an agent that went
114 // to the trouble of presenting a credential is asking to be treated as an
115 // agent, and letting a stale browser cookie promote it to the owner would hand
116 // it the approved branch. The two credentials are checked in that order and
117 // never merged.
118 //
119 // A presented bearer token goes to the tokens.sr.ht plane and nowhere else.
120 // There is no second store behind it since spec stopped minting its own
121 // credential, so every refusal that plane returns is final — see
122 // resolveInstanceToken.
123 //
124 // The error contract is asymmetric on purpose:
125 //
126 // - No bearer token: never an error. The cookie decides between KindOwner and
127 // KindAnonymous, and any cookie problem is anonymity, not failure.
128 // - A bearer token that fails: an error. StatusFor separates the 401 case
129 // (malformed, foreign, revoked) from the 403 case (a good token this
130 // instance has nothing to grant) and the 503 case (tokens.sr.ht
131 // unreachable, or no plane wired at all).
132 //
133 // The agent identity and session headers are read here but not required: they
134 // are demanded at the write, by AgentWrite.Validate, which is the only place
135 // the design requires them and the only place a missing one can do harm.
136 38 func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, error) {
137 38 if presented := BearerFromRequest(r); presented != "" {
138 23 return rs.ResolveAgent(ctx, presented,
139 23 r.Header.Get(HeaderAgent), r.Header.Get(HeaderAgentSession))
140 23 }
141
142 15 username := login.UsernameFromRequest(r, cookieDecode...)
143 15 if username == "" {
144 11 return Anonymous(), nil
145 11 }
146 4 if username != rs.owner {
147 1 // A real user of the instance who is not bigbes. Single-user means
148 1 // there is nothing to grant them, so they read exactly as an anonymous
149 1 // viewer does; the name is kept for the log line and the "you are
150 1 // signed in as" affordance only.
151 1 return Principal{Kind: KindAnonymous, CookieUser: username}, nil
152 1 }
153 3 return Principal{Kind: KindOwner, Owner: username, CookieUser: username}, nil
154 }
155
156 // ResolveAgent authenticates a presented agent credential, with the provenance
157 // the caller collected alongside it, and is what Resolve calls once it has
158 // pulled all three out of an HTTP request.
159 //
160 // It is exported because the push path is not an HTTP request: a `git push`
161 // arrives over SSH and the credential reaches the daemon in a hook's
162 // environment, not in an Authorization header. That path used to check the
163 // agent_token table directly, which is precisely how it ended up unable to
164 // accept an instance token while the HTTP surfaces could. One credential plane
165 // deserves one implementation of "is this credential good?", so hooks calls this
166 // and the two surfaces cannot drift.
167 //
168 // It never returns an anonymous principal on failure: a presented credential
169 // that does not verify is an error, so the caller refuses at the door instead of
170 // silently downgrading an agent to a reader.
171 27 func (rs *Resolver) ResolveAgent(ctx context.Context, presented, agent, session string) (Principal, error) {
172 27 if presented == "" {
173 1 return Anonymous(), ErrNoToken
174 1 }
175 26 if rs.bearer == nil {
176 2 // Not a bad credential: this process cannot check any credential. 503
177 2 // via StatusFor, and the operator's clue is in the message rather than
178 2 // in an agent's incident report about a token that "stopped working".
179 2 return Anonymous(), fmt.Errorf(
180 2 "%w: spec.sr.ht authenticates agents through tokens.sr.ht, and this instance's "+
181 2 "config.ini has no [tokens.sr.ht] origin", ErrNoAgentPlane)
182 2 }
183 24 return rs.resolveInstanceToken(ctx, presented,
184 24 strings.TrimSpace(agent), strings.TrimSpace(session))
185 }
186
187 // Middleware attaches the resolved Principal to the request context, where
188 // PrincipalFromContext reads it.
189 //
190 // It rejects only a failed bearer token — 401 for a bad credential, 403 for a
191 // good one that this instance has nothing to grant, 503 for a backend that could
192 // not answer, per StatusFor. Everything else, including every cookie problem,
193 // flows through as anonymous: the read plane is anonymous-capable and must never
194 // answer an error page on identity grounds.
195 12 func (rs *Resolver) Middleware() func(http.Handler) http.Handler {
196 12 return func(next http.Handler) http.Handler {
197 12 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
198 12 p, err := rs.Resolve(r.Context(), r)
199 12 if err != nil {
200 9 status := StatusFor(err)
201 9 if status >= 500 {
202 4 // Fail closed and loudly. The alternative — degrading to
203 4 // anonymous — would turn a Postgres blip or an unreachable
204 4 // tokens.sr.ht into agents silently losing their write
205 4 // access.
206 4 slog.ErrorContext(r.Context(), "resolving an agent credential failed",
207 4 "method", r.Method, "path", r.URL.Path, "status", status,
208 4 scribe.Err(err))
209 4 }
210 9 if status == http.StatusUnauthorized {
211 4 // RFC 9110 requires the challenge on a 401, and the caller
212 4 // here is always a machine holding a bearer token: naming the
213 4 // scheme and the realm is what tells it which credential this
214 4 // service was refusing.
215 4 w.Header().Set("WWW-Authenticate", Challenge())
216 4 }
217 9 http.Error(w, resolveFailureMessage(status), status)
218 9 return
219 }
220 3 next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
221 })
222 }
223 }
224
225 // resolveFailureMessage is what a refused caller is told. It is keyed on the
226 // status and not on the error, so that nothing about which plane refused, whose
227 // token it was, or whether a row exists leaks to a caller holding a credential
228 // this service did not accept.
229 9 func resolveFailureMessage(status int) string {
230 9 switch status {
231 4 case http.StatusUnauthorized:
232 4 return "invalid agent token"
233 1 case http.StatusForbidden:
234 1 return "this token does not authorize requests to spec.sr.ht"
235 4 default:
236 4 return "authentication backend unavailable"
237 }
238 }