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

Coverage
95.2% 20/21 statements
Δ
Blob
cd61aab
Uncovered L146-L147
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: there is one agent credential plane, so a
71 // refusal here is the service's refusal.
72 //
73 // It used to report a third thing — whether the caller should fall back to
74 // spec's own agent_token store — and exactly two refusals said yes:
75 //
76 // - bearer.ErrInvalid, because spec's local token was 32 random bytes in
77 // base64, which is precisely what "did not decode as one of ours" looks
78 // like;
79 // - bearer.ErrNotOurs, because refusing a meta.sr.ht PAT was the local plane's
80 // business rather than this one's, and falling through cost one hash lookup
81 // that would miss.
82 //
83 // With that store gone both are plain refusals. The one consequence worth
84 // naming is ErrNotOurs: IsAuthFailure now counts it permanent, so a meta PAT
85 // presented here earns a 401 rather than the 503 an unclassified error would.
86 //
87 // The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and
88 // ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and
89 // ErrUnavailable is 503 — never 401, because "I could not ask tokens.sr.ht" is
90 // not "your token is bad".
91 func (rs *Resolver) resolveInstanceToken(
92 ctx context.Context, presented, agent, session string,
93 24 ) (Principal, error) {
94 24 tok, err := rs.bearer.Inspect(ctx, presented)
95 24 if err != nil {
96 13 return Anonymous(), fmt.Errorf("authn: instance token: %w", err)
97 13 }
98
99 // The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
100 // means anything. This is the same rule the cookie plane already applies —
101 // a real user who is not the instance owner reads as nobody — and applying
102 // it here keeps every consumer of Principal.Owner honest: the provenance
103 // committer, the refs rule's principal kind and coreauth's AuthContext all
104 // assume the human an agent acts for is the instance owner, and a foreign
105 // name would make each of them quietly wrong in a different way.
106 //
107 // It is a refusal rather than a downgrade to anonymous because a presented
108 // credential that fails must fail at the door: the asymmetry this package's
109 // doc comment draws between cookies and bearer tokens.
110 11 username := strings.TrimPrefix(tok.Username, "~")
111 11 if username != rs.owner {
112 2 return Anonymous(), fmt.Errorf(
113 2 "%w: the token belongs to ~%s, and this instance answers only to ~%s",
114 2 ErrNotInstanceOwner, username, rs.owner)
115 2 }
116
117 // The owner is resolved to a local row even though single-user spec could
118 // infer it: the row id is what user-scoped state keys off, and looking it up
119 // here is what makes the instance plane's identity a fact about this
120 // database rather than a name copied out of a signed blob.
121 9 user, err := rs.users.LookupUser(ctx, username)
122 9 if err != nil {
123 3 // Unclassified, therefore transient, therefore 503: a database that
124 3 // cannot answer must never read as a bad credential.
125 3 return Anonymous(), fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err)
126 3 }
127
128 6 return Principal{
129 6 Kind: KindAgent,
130 6 Owner: rs.owner,
131 6 Agent: agent,
132 6 Session: session,
133 6 TokenName: instanceTokenLabel(tok),
134 6 Plane: PlaneInstance,
135 6 Grants: tok.Grants,
136 6 UserID: user.ID,
137 6 }, nil
138 }
139
140 // instanceTokenLabel names the credential in a log line. A registered token has
141 // a row at tokens.sr.ht an operator can find and revoke, so its id is the useful
142 // thing to print; a stateless one was never written down, and saying so is more
143 // honest than printing "0".
144 6 func instanceTokenLabel(tok *bearer.Token) string {
145 6 if tok.Registered() {
146 0 return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID)
147 0 }
148 6 return "tokens.sr.ht (stateless)"
149 }
150
151 // StatusFor maps an error out of Resolve — or out of a later Authorize — onto
152 // the status the surface must answer with. It is one function so that the three
153 // surfaces cannot each invent their own table.
154 //
155 // Everything the credential itself can be wrong about is bearer.StatusFor's
156 // answer, not a second copy of it: ErrForbidden is 403, ErrUnavailable is 503
157 // and never 401, and ErrInvalid, ErrRevoked and ErrNotOurs are 401. That last
158 // arm is only reached because ErrNotOurs is decided before we ask — a meta.sr.ht
159 // PAT used to fall through to spec's own token store, and with that store gone
160 // it is a refusal at the door.
161 //
162 // The ErrUnavailable line is the one worth restating even though it is no longer
163 // spelled here. Reading "I could not reach tokens.sr.ht" as "your token is
164 // revoked" would refuse every live instance token for as long as a daemon that
165 // is deliberately off the hot path takes to restart, and would tell a thousand
166 // clients their credentials are bad when the truth is that one service is down.
167 //
168 // What this function adds is what bearer cannot know:
169 //
170 // - ErrMissingGrant and ErrNotInstanceOwner are 403. The credential verifies
171 // and the holder is who they say they are, so retrying is pointless and what
172 // they need is a wider grant, not another login. Both are asked before the
173 // bearer table, because ErrMissingGrant is raised beside a token that
174 // verified and must not be read as one that did not.
175 // - Whatever else IsAuthFailure calls permanent is 401 — today that is
176 // ErrNoToken, nothing having been presented on a surface that requires a
177 // credential. The predicate is asked rather than the sentinel listed a second
178 // time, so that a sentinel added to one of them cannot be missing from the
179 // other: this package's two answers to "is the credential the problem?" have
180 // to agree, and the cheapest way to guarantee that is for one to be built
181 // from the other.
182 // - ErrNoAgentPlane, and anything else at all, is 503. An instance with no
183 // [tokens.sr.ht] origin cannot check any credential, and telling the holder
184 // of a good token that it is bad would send them to re-provision it; an
185 // unclassified error is a backend that could not answer. This is where the
186 // two tables' defaults deliberately differ — bearer's unrecognised failure
187 // is the caller's credential, because everything reaching it is about a
188 // credential, while an unrecognised failure here can be the database this
189 // resolver had to consult, which must never read as a bad token.
190 30 func StatusFor(err error) int {
191 30 switch {
192 1 case err == nil:
193 1 return http.StatusOK
194 5 case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrNotInstanceOwner):
195 5 return http.StatusForbidden
196 16 case isBearerRefusal(err):
197 16 return bearer.StatusFor(err)
198 1 case IsAuthFailure(err):
199 1 return http.StatusUnauthorized
200 7 default:
201 7 return http.StatusServiceUnavailable
202 }
203 }
204
205 // isBearerRefusal reports whether err is one of the sentinels bearer.StatusFor
206 // has an answer for. The list is here rather than in a helper over there because
207 // it is the question "did the shared validator decide this?", and a wrong answer
208 // to it is what would let the 503 default below swallow a 401 — or, worse, let
209 // bearer's own 401 default swallow a database outage.
210 24 func isBearerRefusal(err error) bool {
211 24 return errors.Is(err, bearer.ErrForbidden) ||
212 24 errors.Is(err, bearer.ErrUnavailable) ||
213 24 errors.Is(err, bearer.ErrInvalid) ||
214 24 errors.Is(err, bearer.ErrRevoked) ||
215 24 errors.Is(err, bearer.ErrNotOurs)
216 24 }
217
218 // Challenge is the WWW-Authenticate value every 401 this service answers must
219 // carry, per RFC 9110 §11.6.1 — the scheme, and this service's config section as
220 // the realm, which is what names it in the config, in the nav and in a grant
221 // everywhere else on the instance.
222 //
223 // It is bearer.Challenge with our section already in it, so that the four
224 // surfaces that refuse a credential (the resolver's middleware, MCP, /query and
225 // the read plane's machine formats) cannot name four realms.
226 6 func Challenge() string { return bearer.Challenge(ConfigSection) }