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

Coverage
100.0% 43/43 statements
Δ
Blob
b06b699
Uncovered nothing — every instrumented line ran
1 package authn
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "strings"
9
10 "sourcecraft.dev/bigbes/sr-ht-core/auth"
11
12 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
13 "sourcecraft.dev/bigbes/sr-ht-ecore/grants"
14
15 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
16 )
17
18 // AuthMethodInstanceToken labels an AuthContext resolved from a tokens.sr.ht
19 // working token. Like AuthMethodDoltKey it is a private label: core-go has no
20 // such method, and we never call auth.AuthContext.Access (which would panic on
21 // an unknown one) — access is decided by core.Allowed — so the label costs
22 // nothing and keeps this plane distinguishable from a meta.sr.ht PAT in a log
23 // line or a debugger.
24 const AuthMethodInstanceToken = "INSTANCE_TOKEN"
25
26 // bearerScheme is the RFC 7235 auth-scheme this plane answers to. The
27 // comparison against it is case-insensitive, as that RFC requires.
28 const bearerScheme = "Bearer"
29
30 // ErrMissingGrant is the sentinel wrapped by every "the credential is good, it
31 // does not cover this" refusal: a working token without core.GrantRead, or a
32 // meta PAT whose OAuth grants do not reach dolt.sr.ht repositories. It is a 403
33 // — retrying with the same token is pointless, and the holder needs to be told
34 // to ask for a wider grant rather than to authenticate again.
35 //
36 // Where it is returned by ResolveBearer it is joined with ErrInvalidToken, so
37 // that the two-class contract of backend.go stays total: a caller that only
38 // knows "wraps ErrInvalidToken ⇒ permanent, otherwise ⇒ transient" still
39 // answers 401 and not 503, while a caller that can say 403 asks for this
40 // sentinel first. Authorize is not part of that contract — it is asked after
41 // resolution succeeded, by a surface that already knows this package — so it
42 // wraps this sentinel alone.
43 var ErrMissingGrant = errors.New("authn: credential does not carry the required grant")
44
45 // ParseBearer returns the token from an "Authorization: Bearer <token>" header,
46 // or "" when the header is absent or names another scheme.
47 //
48 // Another scheme is silently no token rather than an error. Basic is dolt's own
49 // remote flow (ResolveBasic) and is not this plane's to reject; a request
50 // holding one, or holding nothing at all, must fall through to whatever the
51 // caller does with an anonymous request, not be refused in the name of a
52 // credential it never claimed to present.
53 10 func ParseBearer(r *http.Request) string {
54 10 h := r.Header.Get("Authorization")
55 10 if h == "" {
56 1 return ""
57 1 }
58 9 scheme, rest, ok := strings.Cut(h, " ")
59 9 if !ok || !strings.EqualFold(scheme, bearerScheme) {
60 4 return ""
61 4 }
62 5 return strings.TrimSpace(rest)
63 }
64
65 // InstanceValidator is the slice of sr-ht-ecore's bearer.Validator this plane
66 // uses: the four steps of the tokens SPEC ch. 6 minus the grant check, which is
67 // asked where the action is known and therefore not here (see bearer.Inspect,
68 // and BearerCaller.Authorize below).
69 //
70 // The interface is declared in the consumer, as MetaBackend is and for the same
71 // reason: it states exactly how much of the shared validator this service
72 // depends on — one method — and it is what lets both arms of ResolveBearer be
73 // tested without a tokens.sr.ht, without a network and without Postgres.
74 type InstanceValidator interface {
75 // Inspect verifies signature, version and expiry locally, checks that the
76 // token is one tokens.sr.ht sealed, and asks the daemon whether a registered
77 // token is still live. It answers with the owner, the parsed grants and the
78 // row id, or with one of the bearer package's sentinels.
79 Inspect(ctx context.Context, presented string) (*bearer.Token, error)
80 }
81
82 // Compile-time proof that the shared validator satisfies the port; it is what
83 // lets this package depend on the interface rather than on *bearer.Validator,
84 // and it fails the build the moment either side drifts.
85 var _ InstanceValidator = (*bearer.Validator)(nil)
86
87 // BearerCaller is what a presented bearer credential resolves to: the identity,
88 // and — for a working token — what that token is allowed to ask for.
89 //
90 // It is deliberately small and it is not a second caller model. AuthContext is
91 // the same *auth.AuthContext every other plane in this package produces, so
92 // AsCoreCaller keeps working and the access matrix in package core is unchanged;
93 // this type exists only so that a surface can ask the one further question a
94 // tokens.sr.ht credential brings with it ("may this token do X?"), which an
95 // AuthContext has no vocabulary for.
96 type BearerCaller struct {
97 // AuthContext is the resolved identity, never nil on a successful resolve.
98 AuthContext *auth.AuthContext
99
100 // InstanceToken reports which of the two bearer shapes this was: a
101 // tokens.sr.ht working token (true) or a meta.sr.ht personal access token
102 // (false). Only the ClientID distinguishes them — both are sealed with the
103 // same instance key — and the difference decides which grant vocabulary
104 // applies below.
105 InstanceToken bool
106
107 // Grants is the tokens.sr.ht grant set of a working token, parsed by
108 // sr-ht-ecore/grants and by nothing else. It is the zero value — which
109 // admits nothing — for a meta PAT, whose grants are in core-go's entirely
110 // different OAuth vocabulary and live on AuthContext.Grants instead. Ask
111 // Authorize rather than reading this field, so the distinction stays in one
112 // place.
113 Grants grants.Grants
114 }
115
116 // Authorize reports whether this caller may perform the named action, e.g.
117 // core.GrantRead.
118 //
119 // A meta PAT passes unconditionally, and that is not a hole. It carries no
120 // tokens.sr.ht grants at all — the vocabularies do not overlap — and its
121 // scoping was already applied at resolve time, by the same TokenGrantsAllow
122 // gate the clone path applies (docs/DESIGN.mcp.md §4.2: a meta PAT and an
123 // anonymous caller pass this gate; their access is decided by core.Allowed).
124 // Refusing it here would instead refuse every PAT on the surface, since no PAT
125 // can ever be minted with a grant string tokens.sr.ht's parser would even read.
126 //
127 // A refusal wraps ErrMissingGrant: the credential is good and the caller is who
128 // they say they are, and what is missing is a permission.
129 4 func (c *BearerCaller) Authorize(grant string) error {
130 4 if !c.InstanceToken {
131 1 return nil
132 1 }
133 3 if !c.Grants.Has(grant) {
134 1 return fmt.Errorf("%w: %q is not in %q", ErrMissingGrant, grant, c.Grants.String())
135 1 }
136 2 return nil
137 }
138
139 // ResolveBearer resolves the caller for a bearer credential — the only machine
140 // credential the /mcp surface accepts (docs/DESIGN.mcp.md §4.1).
141 //
142 // The instance issues two bearer shapes, both auth.BearerToken values sealed
143 // with the same instance key, and only the ClientID tells them apart:
144 //
145 // - bearer.TokensClientID ⇒ a tokens.sr.ht working token. Verified through
146 // sr-ht-ecore's validator (signature, version, expiry, ours-ness and, for a
147 // registered token, the liveness check against the daemon), its owner
148 // mirrored through the same MetaBackend the other planes use, and its grants
149 // carried out on the result for BearerCaller.Authorize.
150 // - anything else ⇒ a meta.sr.ht personal access token, resolved by
151 // ResolveBasic — the decode/lookup/revocation path this package already has.
152 // The one difference from the clone flow is that there is no presented
153 // username to compare against, so the token's own username *is* the
154 // identity. It is then gated by TokenGrantsAllow at core.AccessRO, the check
155 // the clone path applies for a read.
156 //
157 // The ClientID is read here, by decoding the token once locally, rather than by
158 // handing everything to Inspect and routing on bearer.ErrNotOurs. Both arms have
159 // to work when there is no validator at all (see below), so the routing cannot
160 // live inside the validator; and having it in one place beats having it twice.
161 // The cost is one extra local HMAC on the working-token arm, which is the
162 // cheapest step of the four.
163 //
164 // v may be nil, and that is a configuration rather than a degradation: an
165 // instance whose config.ini has no [tokens.sr.ht] section has no such daemon.
166 // Meta PATs and anonymity keep working; a working token is then refused with
167 // ErrInvalidToken, because a machine credential this instance cannot verify is
168 // refused and not guessed at. (A *typed* nil — (*bearer.Validator)(nil) in an
169 // InstanceValidator — is not that contract and will panic; pass a plain nil.)
170 //
171 // Failure is a refusal and never a downgrade to anonymous. An empty presented
172 // string is refused too: anonymity is the caller's decision, taken before this
173 // function is reached (ParseBearer returning "" is what it is taken on), and an
174 // empty credential arriving here is a caller that lost track of its own header.
175 //
176 // The error classes are backend.go's, unchanged: a permanent rejection wraps
177 // ErrInvalidToken (401, additionally ErrMissingGrant for a 403), anything else
178 // is a transient backend failure returned unwrapped (503).
179 22 func ResolveBearer(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) {
180 22 if presented == "" {
181 1 return nil, fmt.Errorf("%w: no bearer token presented", ErrInvalidToken)
182 1 }
183
184 // Step 1 of the tokens SPEC for both arms at once: signature, version and
185 // expiry, all local. A forged or expired credential costs one HMAC and never
186 // becomes a request to meta.sr.ht or to tokens.sr.ht.
187 21 bt := auth.DecodeBearerToken(presented)
188 21 if bt == nil {
189 3 return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken)
190 3 }
191
192 18 if bt.ClientID == bearer.TokensClientID {
193 12 return resolveWorkingToken(ctx, v, presented)
194 12 }
195 6 return resolveMetaPAT(ctx, bt.Username, presented)
196 }
197
198 // resolveWorkingToken is the tokens.sr.ht arm.
199 12 func resolveWorkingToken(ctx context.Context, v InstanceValidator, presented string) (*BearerCaller, error) {
200 12 if v == nil {
201 1 return nil, fmt.Errorf(
202 1 "%w: this instance configures no [tokens.sr.ht] origin, so a working token cannot be verified",
203 1 ErrInvalidToken)
204 1 }
205
206 11 tok, err := v.Inspect(ctx, presented)
207 11 if err != nil {
208 6 return nil, classifyInspect(err)
209 6 }
210
211 // The token names a meta.sr.ht account and nothing else; turning that into a
212 // local row is the service's job (bearer's package doc says so, and the
213 // tokens SPEC ch. 6 prescribes it for every service on the instance). It is
214 // the same call the Basic path makes, through the same seam.
215 5 var ac auth.AuthContext
216 5 if err := meta.LookupUser(ctx, tok.Username, &ac); err != nil {
217 1 // Transient: meta or the database could not answer. The credential is
218 1 // good, and telling an agent to re-mint over a lookup outage is the wrong
219 1 // instruction twice — it does not help, and it destroys a working token.
220 1 return nil, fmt.Errorf("looking up user %q: %w", tok.Username, err)
221 1 }
222 4 if ac.UserID == 0 {
223 1 // LookupUser answered without filling in an id. Nothing downstream can
224 1 // use that: every ownership and ACL row keys on the user id, and a zero
225 1 // would match the first repository whose owner id is unset. Permanent
226 1 // rather than transient — retrying will not conjure the account back.
227 1 return nil, fmt.Errorf("%w: working token names %q, for whom no meta id was mirrored",
228 1 ErrInvalidToken, tok.Username)
229 1 }
230
231 3 ac.AuthMethod = AuthMethodInstanceToken
232 3 // BearerToken and Grants are deliberately left unset. They are core-go's
233 3 // OAuth fields, and filling them would subject this caller to
234 3 // TokenGrantsAllow — a gate demanding "dolt.sr.ht/repos:RO", which a
235 3 // tokens.sr.ht grant string can never spell. A working token is scoped by
236 3 // its own vocabulary, on BearerCaller.Grants, and by core.Allowed.
237 3
238 3 return &BearerCaller{
239 3 AuthContext: &ac,
240 3 InstanceToken: true,
241 3 Grants: tok.Grants,
242 3 }, nil
243 }
244
245 // resolveMetaPAT is the meta.sr.ht arm: ResolveBasic with the token's own
246 // username standing in for the presented one, plus the read gate.
247 6 func resolveMetaPAT(ctx context.Context, username, presented string) (*BearerCaller, error) {
248 6 // Passing the token's own username makes ResolveBasic's impersonation check
249 6 // a tautology, which is correct here and only here: that check exists to
250 6 // stop a token being used *as* another user's password, and there is no
251 6 // second party's name in a bearer header to be checked against. Everything
252 6 // else it does — the positive cache, the profile mirror, the revocation
253 6 // check, the grant decode — is exactly what this arm needs, and is the
254 6 // reason this is a call and not a copy.
255 6 ac, err := ResolveBasic(ctx, username, presented)
256 6 if err != nil {
257 2 return nil, err
258 2 }
259
260 // The whole surface is a read, so the gate can be applied once here rather
261 // than per action. It is the same check the clone path applies.
262 4 if !TokenGrantsAllow(ac, core.AccessRO) {
263 1 return nil, fmt.Errorf("%w: %w: token grants do not permit %s on %s repositories",
264 1 ErrInvalidToken, ErrMissingGrant, core.AccessRO, RepoScope)
265 1 }
266
267 3 return &BearerCaller{AuthContext: ac, InstanceToken: false}, nil
268 }
269
270 // classifyInspect maps sr-ht-ecore's sentinels onto this package's two error
271 // classes. It is the one place this service decides what each refusal of the
272 // shared validator means here.
273 //
274 // The 401/503 split is the one that is easy to get wrong and expensive to get
275 // wrong: an unreachable tokens.sr.ht must not read as a bad credential. "I could
276 // not check" is not "your token is revoked", and answering 401 there would turn
277 // a restart of a daemon deliberately kept off the hot path into every agent on
278 // the instance being told to re-mint its credentials.
279 //
280 // bearer.ErrNotOurs is classified for totality and is not reachable: ResolveBearer
281 // routes on the ClientID before Inspect is called, so a foreign token has already
282 // gone to the meta arm.
283 //
284 // An unrecognised error is transient, which is the fail-closed direction here: a
285 // sentinel this table has never seen must read as "I could not decide" — a 503
286 // the caller retries — never as a verdict about the credential.
287 6 func classifyInspect(err error) error {
288 6 switch {
289 case errors.Is(err, bearer.ErrInvalid),
290 errors.Is(err, bearer.ErrNotOurs),
291 3 errors.Is(err, bearer.ErrRevoked):
292 3 return fmt.Errorf("%w: %w", ErrInvalidToken, err)
293 1 case errors.Is(err, bearer.ErrForbidden):
294 1 // Not reachable through Inspect, which is not told an action; classified
295 1 // so the table is total. Joined with ErrInvalidToken for the reason
296 1 // ErrMissingGrant's own comment gives.
297 1 return fmt.Errorf("%w: %w: %w", ErrInvalidToken, ErrMissingGrant, err)
298 1 case errors.Is(err, bearer.ErrUnavailable):
299 1 return fmt.Errorf("asking tokens.sr.ht whether a working token is live: %w", err)
300 1 default:
301 1 return fmt.Errorf("validating a tokens.sr.ht working token: %w", err)
302 }
303 }