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

Coverage
95.2% 20/21 statements
Δ
+0.0
Blob
df0f920
Uncovered L150-L151
1 package authn
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "strconv"
9 "strings"
10
11 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
12 )
13
14 // The grant vocabulary spec.sr.ht declares for tokens.sr.ht working tokens.
15 //
16 // The daemon that mints them does not know these strings and must not: the
17 // tokens.sr.ht spec gives the vocabulary to the services, so that adding an
18 // action to spec.sr.ht is a change to spec.sr.ht. An unknown grant simply
19 // admits nobody. They are constants rather than literals at the check because a
20 // grant is compared byte for byte — a typo in one of the two places it is
21 // spelled is a silent widening or a silent refusal, and neither shows up until
22 // it matters.
23 const (
24 // ActionPropose is what an instance token must carry to write: open a
25 // proposal or add documents to one.
26 ActionPropose = "spec:propose"
27
28 // ActionRead is what an instance token must carry to read content through
29 // any of the read surfaces (the web UI, /query, MCP).
30 ActionRead = "spec:read"
31 )
32
33 // BearerValidator is the sliver of sr-ht-ecore's bearer.Validator this package
34 // needs: who a presented working token belongs to, what it permits, and whether
35 // it is still live.
36 //
37 // Inspect and not Validate, because the resolver runs in middleware upstream of
38 // the router and so does not know which action is being attempted. The grant
39 // check happens where the action is known — service.Propose for the write
40 // plane, the read gates for the read plane — through Principal.Authorize.
41 //
42 // It is an interface rather than a *bearer.Validator so that this package stays
43 // testable without a tokens.sr.ht to talk to, exactly as TokenStore keeps it
44 // testable without a Postgres.
45 type BearerValidator interface {
46 Inspect(ctx context.Context, presented string) (*bearer.Token, error)
47 }
48
49 // InstanceUser is the local "user" row that the owner of an instance token
50 // resolves to. It is the whole of what this package needs from that row: the id
51 // other layers key user-scoped state by, and the name it was found under.
52 type InstanceUser struct {
53 ID int
54 Username string
55 }
56
57 // UserLookup resolves the meta.sr.ht username an instance token names into this
58 // service's local user row — core-go's auth.LookupUser in production.
59 //
60 // It is declared here for the same reason TokenStore is: that function reads a
61 // database handle and a config out of the context and panics without either,
62 // which is service/'s business to supply and not something a package answering
63 // "who is making this request?" should carry. service/ wires the real one in;
64 // tests wire a map.
65 type UserLookup interface {
66 LookupUser(ctx context.Context, username string) (InstanceUser, error)
67 }
68
69 // resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer
70 // credential. Its answer is final: this plane has nothing behind it, so a refusal
71 // here is the service's refusal on every surface that reaches it. /query is the
72 // one that may not — it routes a PAT to MetaAuth before asking this — and that
73 // choice is made on the credential, upstream, rather than by this function
74 // handing a refusal on to somebody else.
75 //
76 // It used to report a third thing — whether the caller should fall back to
77 // spec's own agent_token store — and exactly two refusals said yes:
78 //
79 // - bearer.ErrInvalid, because spec's local token was 32 random bytes in
80 // base64, which is precisely what "did not decode as one of ours" looks
81 // like;
82 // - bearer.ErrNotOurs, because refusing a meta.sr.ht PAT was the local plane's
83 // business rather than this one's, and falling through cost one hash lookup
84 // that would miss.
85 //
86 // With that store gone both are plain refusals. The one consequence worth
87 // naming is ErrNotOurs: IsAuthFailure counts it permanent, so a meta PAT that
88 // does reach this plane earns a 401 rather than the 503 an unclassified error
89 // would.
90 //
91 // The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and
92 // ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and
93 // ErrUnavailable is 503 — never 401, because "I could not ask tokens.sr.ht" is
94 // not "your token is bad".
95 func (rs *Resolver) resolveInstanceToken(
96 ctx context.Context, presented, agent, session string,
97 24 ) (Principal, error) {
98 24 tok, err := rs.bearer.Inspect(ctx, presented)
99 24 if err != nil {
100 13 return Anonymous(), fmt.Errorf("authn: instance token: %w", err)
101 13 }
102
103 // The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
104 // means anything. This is the same rule the cookie plane already applies —
105 // a real user who is not the instance owner reads as nobody — and applying
106 // it here keeps every consumer of Principal.Owner honest: the provenance
107 // committer, the refs rule's principal kind and coreauth's AuthContext all
108 // assume the human an agent acts for is the instance owner, and a foreign
109 // name would make each of them quietly wrong in a different way.
110 //
111 // It is a refusal rather than a downgrade to anonymous because a presented
112 // credential that fails must fail at the door: the asymmetry this package's
113 // doc comment draws between cookies and bearer tokens.
114 11 username := strings.TrimPrefix(tok.Username, "~")
115 11 if username != rs.owner {
116 2 return Anonymous(), fmt.Errorf(
117 2 "%w: the token belongs to ~%s, and this instance answers only to ~%s",
118 2 ErrNotInstanceOwner, username, rs.owner)
119 2 }
120
121 // The owner is resolved to a local row even though single-user spec could
122 // infer it: the row id is what user-scoped state keys off, and looking it up
123 // here is what makes the instance plane's identity a fact about this
124 // database rather than a name copied out of a signed blob.
125 9 user, err := rs.users.LookupUser(ctx, username)
126 9 if err != nil {
127 3 // Unclassified, therefore transient, therefore 503: a database that
128 3 // cannot answer must never read as a bad credential.
129 3 return Anonymous(), fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err)
130 3 }
131
132 6 return Principal{
133 6 Kind: KindAgent,
134 6 Owner: rs.owner,
135 6 Agent: agent,
136 6 Session: session,
137 6 TokenName: instanceTokenLabel(tok),
138 6 Plane: PlaneInstance,
139 6 Grants: tok.Grants,
140 6 UserID: user.ID,
141 6 }, nil
142 }
143
144 // instanceTokenLabel names the credential in a log line. A registered token has
145 // a row at tokens.sr.ht an operator can find and revoke, so its id is the useful
146 // thing to print; a stateless one was never written down, and saying so is more
147 // honest than printing "0".
148 6 func instanceTokenLabel(tok *bearer.Token) string {
149 6 if tok.Registered() {
150 0 return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID)
151 0 }
152 6 return "tokens.sr.ht (stateless)"
153 }
154
155 // StatusFor maps an error out of Resolve — or out of a later Authorize, or out
156 // of MetaAuth.VerifyToken — onto the status the surface must answer with. It is
157 // one function so that the surfaces cannot each invent their own table, and one
158 // function across both credential planes so that the two cannot answer a client
159 // differently for the same kind of failure. That is the whole of what makes them
160 // consistent: the prose of a refusal is per-plane and its status is not.
161 //
162 // Everything the credential itself can be wrong about is bearer.StatusFor's
163 // answer, not a second copy of it: ErrForbidden is 403, ErrUnavailable is 503
164 // and never 401, and ErrInvalid, ErrRevoked and ErrNotOurs are 401. That last
165 // arm is only reached because ErrNotOurs is decided before we ask — a meta.sr.ht
166 // PAT used to fall through to spec's own token store, and with that store gone
167 // it is a refusal at the door of every surface but /query, which routes one to
168 // MetaAuth instead.
169 //
170 // The ErrUnavailable line is the one worth restating even though it is no longer
171 // spelled here. Reading "I could not reach tokens.sr.ht" as "your token is
172 // revoked" would refuse every live instance token for as long as a daemon that
173 // is deliberately off the hot path takes to restart, and would tell a thousand
174 // clients their credentials are bad when the truth is that one service is down.
175 //
176 // What this function adds is what bearer cannot know:
177 //
178 // - ErrMissingGrant, ErrMissingScope and ErrNotInstanceOwner are 403. The
179 // credential verifies and the holder is who they say they are, so retrying is
180 // pointless and what they need is a wider permission, not another login. All
181 // three are asked before the bearer table, because each is raised beside a
182 // token that verified and must not be read as one that did not.
183 // - Whatever else IsAuthFailure calls permanent is 401 — ErrNoToken, nothing
184 // having been presented on a surface that requires a credential, and
185 // ErrInvalidPersonalToken, the meta plane's counterpart of the bearer
186 // sentinels above. The predicate is asked rather than its members listed a
187 // second time, so that a sentinel added to one of them cannot be missing from
188 // the other: this package's two answers to "is the credential the problem?"
189 // have to agree, and the cheapest way to guarantee that is for one to be
190 // built from the other.
191 // - ErrNoAgentPlane, ErrMetaUnavailable, and anything else at all, is 503. An
192 // instance with no [tokens.sr.ht] origin cannot check any credential, a
193 // meta.sr.ht that will not answer means this one could not be checked, and
194 // telling the holder of a good token that it is bad would send them to
195 // re-provision it; an unclassified error is a backend that could not answer.
196 // This is where the two tables' defaults deliberately differ — bearer's
197 // unrecognised failure is the caller's credential, because everything reaching
198 // it is about a credential, while an unrecognised failure here can be the
199 // database this resolver had to consult, which must never read as a bad token.
200 // The meta plane leans on that default rather than being listed: its
201 // classification is written to land on this arm for a sentinel nobody has seen
202 // before, which is the fail-closed direction.
203 45 func StatusFor(err error) int {
204 45 switch {
205 1 case err == nil:
206 1 return http.StatusOK
207 case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrMissingScope),
208 9 errors.Is(err, ErrNotInstanceOwner):
209 9 return http.StatusForbidden
210 16 case isBearerRefusal(err):
211 16 return bearer.StatusFor(err)
212 8 case IsAuthFailure(err):
213 8 return http.StatusUnauthorized
214 11 default:
215 11 return http.StatusServiceUnavailable
216 }
217 }
218
219 // isBearerRefusal reports whether err is one of the sentinels bearer.StatusFor
220 // has an answer for. The list is here rather than in a helper over there because
221 // it is the question "did the shared validator decide this?", and a wrong answer
222 // to it is what would let the 503 default below swallow a 401 — or, worse, let
223 // bearer's own 401 default swallow a database outage.
224 35 func isBearerRefusal(err error) bool {
225 35 return errors.Is(err, bearer.ErrForbidden) ||
226 35 errors.Is(err, bearer.ErrUnavailable) ||
227 35 errors.Is(err, bearer.ErrInvalid) ||
228 35 errors.Is(err, bearer.ErrRevoked) ||
229 35 errors.Is(err, bearer.ErrNotOurs)
230 35 }
231
232 // Challenge is the WWW-Authenticate value every 401 this service answers must
233 // carry, per RFC 9110 §11.6.1 — the scheme, and this service's config section as
234 // the realm, which is what names it in the config, in the nav and in a grant
235 // everywhere else on the instance.
236 //
237 // It is bearer.Challenge with our section already in it, so that the four
238 // surfaces that refuse a credential (the resolver's middleware, MCP, /query and
239 // the read plane's machine formats) cannot name four realms.
240 6 func Challenge() string { return bearer.Challenge(ConfigSection) }