coverage~bigbes/sr-ht-ecore89fa694cmetapat/metapat.go

Coverage
97.6% 82/84 statements
Δ
Blob
ea41cf9
Uncovered L230-L232L234-L236
1 // Package metapat validates a meta.sr.ht personal access token, and is the one
2 // copy of that check for every service on the instance that accepts one beside
3 // a tokens.sr.ht working token.
4 //
5 // # Why a service needs both planes
6 //
7 // The instance seals two bearer shapes with the same key, and only the ClientID
8 // tells them apart (bearer.TokensClientID). Which one a surface accepts is not a
9 // matter of taste:
10 //
11 // - api.sr.ht forwards ONE client "Authorization" header to every service a
12 // federated query touches — its AuthMiddleware copies the header verbatim
13 // into the request context, and the Internal credential it can mint is used
14 // only to fetch schemas at startup. So a federated query carries whatever
15 // credential the client had, to all of its services at once. A GraphQL
16 // endpoint that refuses meta PATs therefore cannot be federated: the first
17 // authenticated query that reaches it answers 401.
18 // - Every upstream service on the instance authenticates machine callers with
19 // a meta PAT, so a meta PAT is the only credential a client can hold that
20 // works instance-wide.
21 //
22 // A tokens.sr.ht working token remains the credential of the surfaces that are
23 // not federated — the REST uploads and the MCP endpoints — because those are
24 // where a narrow, short-lived, revocable grant is worth its cost. This package
25 // is what lets one service hold both without writing the PAT path four times.
26 //
27 // # What this package does and does not decide
28 //
29 // It answers exactly one question: is this presented string a live meta.sr.ht
30 // personal access token, and whose? Resolving that into the service's own notion
31 // of a caller, choosing an HTTP status for each refusal, and deciding what the
32 // caller may then see are all the service's, as they are for bearer.
33 //
34 // The steps, in order, and the order is the point — everything that can refuse
35 // locally runs before anything that touches the network:
36 //
37 // 1. decode and verify the signature and expiry (local, no network);
38 // 2. is this a PAT at all, or a working token wearing the same envelope?
39 // 3. mirror the owner's profile from meta.sr.ht;
40 // 4. ask meta.sr.ht whether the token has been revoked.
41 //
42 // Steps 3 and 4 are cached together for CacheTTL, so a burst of federated
43 // queries carrying one PAT costs one pair of lookups rather than one per field
44 // resolver.
45 //
46 // Scope enforcement is deliberately not part of resolution. A PAT carries
47 // core-go's OAuth grant vocabulary ("cov.sr.ht/REPORTS:RO"), the surface knows
48 // which scope and which mode it is about to exercise, and Allows is where the
49 // two meet.
50 //
51 // Usage:
52 //
53 // v, err := metapat.New(metapat.Options{Service: "cov.sr.ht"})
54 // ...
55 // switch metapat.PlaneOf(presented) {
56 // case metapat.PlaneWorking:
57 // tok, err := workingTokens.Inspect(ctx, presented)
58 // ...
59 // case metapat.PlaneMeta:
60 // ac, err := v.Resolve(ctx, presented)
61 // if err == nil && !metapat.Allows(ac, "cov.sr.ht/REPORTS", auth.RO) {
62 // // 403
63 // }
64 // }
65 //
66 // The process must have run crypto.InitCrypto before any of this: the signing
67 // key step 1 verifies against lives in that package's globals. This is the same
68 // precondition every core-go authentication path carries, and it is not checked
69 // here, because there is nothing this package could usefully do about it at
70 // request time.
71 package metapat
72
73 import (
74 "context"
75 "crypto/sha512"
76 "errors"
77 "fmt"
78 "strings"
79 "sync"
80 "time"
81
82 "github.com/vaughan0/go-ini"
83
84 "sourcecraft.dev/bigbes/sr-ht-core/auth"
85 "sourcecraft.dev/bigbes/sr-ht-core/config"
86
87 "sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
88 )
89
90 // DefaultCacheTTL is how long one resolution is reused when Options leaves
91 // CacheTTL at zero.
92 //
93 // Sixty seconds, matching the figure the tokens SPEC names for the working-token
94 // revocation check, and for the same trade: it bounds how long a revoked
95 // credential keeps working, against how hard a single agent's request loop hits
96 // meta.sr.ht.
97 const DefaultCacheTTL = 60 * time.Second
98
99 // maxCacheEntries bounds the resolution cache. See (*Validator).remember for
100 // what happens at the bound and why that is the right thing to happen.
101 const maxCacheEntries = 4096
102
103 // The refusals of this package, and the status each one is for a service.
104 //
105 // They are separate sentinels rather than one error with a code because the
106 // mapping is not uniform, and they are spelled to mirror bearer's, so that a
107 // service holding both planes writes one classification table and not two:
108 //
109 // - ErrInvalid — 401. The signature did not verify, the token has expired, or
110 // it names an account meta.sr.ht will not resolve.
111 // - ErrNotOurs — the service's own policy, not a status. See Resolve.
112 // - ErrForbidden — 403. The credential is good; its OAuth grants do not cover
113 // what is being attempted. Returned by callers of Allows, never by Resolve.
114 // - ErrRevoked — 401. The token was withdrawn by its owner.
115 // - ErrUnavailable — 503. meta.sr.ht could not be asked.
116 //
117 // The 503 is the one that has to be defended, for the reason bearer's own
118 // sentinels give at length: "I could not check" is not "your credential is bad",
119 // and answering 401 to a meta.sr.ht outage tells every client on the instance to
120 // go and re-mint credentials that were never broken. It is also what core-go
121 // itself answers when meta cannot be reached, so a service that classifies this
122 // way stays consistent with the upstream services beside it.
123 var (
124 // ErrInvalid: the presented string is not a personal access token this
125 // instance sealed, or no longer is one. 401.
126 ErrInvalid = errors.New("metapat: token does not verify")
127
128 // ErrNotOurs: a well-formed token sealed by tokens.sr.ht rather than by
129 // meta.sr.ht. Returned so that a service which routes on failure rather than
130 // on PlaneOf still gets a total answer; the status is the service's to
131 // choose, and for a service holding both planes it is not a refusal at all.
132 ErrNotOurs = errors.New("metapat: token was issued by tokens.sr.ht, not meta.sr.ht")
133
134 // ErrForbidden: the token is good and does not carry the scope. 403. This
135 // package returns it from no function — Allows answers a bool — and it is
136 // exported so that the service's refusal has a sentinel to wrap that belongs
137 // to the same table as the rest.
138 ErrForbidden = errors.New("metapat: token does not carry the required OAuth scope")
139
140 // ErrRevoked: meta.sr.ht reports the token as revoked. 401 and not 403: it
141 // is no longer a credential at all, and a client shown 403 will keep
142 // presenting it.
143 ErrRevoked = errors.New("metapat: token has been revoked")
144
145 // ErrUnavailable: the profile mirror or the revocation check could not be
146 // completed. 503, never 401 — see above.
147 ErrUnavailable = errors.New("metapat: meta.sr.ht could not be reached")
148 )
149
150 // Plane says which of the instance's two bearer planes sealed a credential.
151 type Plane int
152
153 const (
154 // PlaneUnknown: the string does not decode as a bearer token this instance
155 // sealed at all — forged, corrupted, expired, or simply not a token.
156 PlaneUnknown Plane = iota
157
158 // PlaneWorking: a tokens.sr.ht working token.
159 PlaneWorking
160
161 // PlaneMeta: a meta.sr.ht personal access token.
162 PlaneMeta
163 )
164
165 // String names the plane, for a log line.
166 4 func (p Plane) String() string {
167 4 switch p {
168 1 case PlaneWorking:
169 1 return "tokens.sr.ht working token"
170 1 case PlaneMeta:
171 1 return "meta.sr.ht personal access token"
172 2 default:
173 2 return "unrecognised credential"
174 }
175 }
176
177 // PlaneOf reports which plane a presented credential belongs to, without
178 // resolving it — one local HMAC and no network.
179 //
180 // This is how a service routes, and routing here rather than on the failure of
181 // one plane matters for a reason that is easy to miss: an instance whose config
182 // has no [tokens.sr.ht] section holds no working-token validator at all, and its
183 // meta PAT plane must keep working anyway. A service that routed by calling the
184 // working-token validator first and catching bearer.ErrNotOurs would have
185 // nothing to call.
186 //
187 // PlaneUnknown is not a verdict about the credential's issuer, only about this
188 // process's ability to read it. An expired token of either plane lands here,
189 // because auth.DecodeBearerToken checks expiry before it reports anything — so a
190 // service should answer PlaneUnknown with the same 401 it gives ErrInvalid,
191 // rather than treating it as "no credential presented".
192 10 func PlaneOf(presented string) Plane {
193 10 bt := auth.DecodeBearerToken(presented)
194 10 if bt == nil {
195 5 return PlaneUnknown
196 5 }
197 5 if bt.ClientID == bearer.TokensClientID {
198 2 return PlaneWorking
199 2 }
200 3 return PlaneMeta
201 }
202
203 // Backend is the meta.sr.ht half of the check, declared here as an interface so
204 // that every arm of Resolve is testable without a meta.sr.ht, without a network
205 // and without a database.
206 //
207 // Both methods are core-go calls in production (CoreBackend), and both may hit
208 // the network: LookupUser falls back to an internal GraphQL query when the local
209 // mirror misses, and IsRevoked always asks.
210 type Backend interface {
211 // LookupUser mirrors a meta.sr.ht profile into out, filling in at least
212 // UserID and Username. An error is transient by contract — the account may
213 // exist and meta may simply be unreachable.
214 LookupUser(ctx context.Context, username string, out *auth.AuthContext) error
215
216 // IsRevoked reports whether the personal access token with this sha512 has
217 // been revoked by its owner. clientID is the token's own, which is what
218 // scopes the revocation row.
219 IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error)
220 }
221
222 // coreBackend is the production Backend: core-go, unadorned.
223 type coreBackend struct{}
224
225 // Compile-time proof that the production backend satisfies the port. Its two
226 // methods are the only lines in this package a test cannot reach — they need a
227 // meta.sr.ht — so this is what stands between them and a signature drift.
228 var _ Backend = coreBackend{}
229
230 0 func (coreBackend) LookupUser(ctx context.Context, username string, out *auth.AuthContext) error {
231 0 return auth.LookupUser(ctx, username, out)
232 0 }
233
234 0 func (coreBackend) IsRevoked(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) {
235 0 return auth.LookupTokenRevocation(ctx, username, hash, clientID)
236 0 }
237
238 // CoreBackend returns the production backend, the one Options selects when
239 // Backend is nil. It is exported so that a service wrapping it — to add a metric
240 // or a log line — has something to embed.
241 1 func CoreBackend() Backend { return coreBackend{} }
242
243 // Options configures a Validator.
244 type Options struct {
245 // Service is this service's name as meta.sr.ht spells it in a grant,
246 // e.g. "cov.sr.ht". Required.
247 //
248 // It is required for a reason that is invisible until it is not:
249 // auth.DecodeGrants reads the *calling* service's name off the context, to
250 // expand a grant written without one, and config.ServiceName PANICS rather
251 // than returning "" when nothing put it there. In production nothing puts it
252 // there except core-go's config.Middleware, so a validator that relied on the
253 // ambient context would work behind an HTTP router and take the process down
254 // anywhere else — a background job, a CLI, a test. Naming the service here
255 // makes this package answerable to its own caller instead.
256 Service string
257
258 // Backend performs the meta.sr.ht lookups. Nil means CoreBackend().
259 Backend Backend
260
261 // CacheTTL is how long one resolution is reused. Zero means
262 // DefaultCacheTTL; negative is refused.
263 CacheTTL time.Duration
264
265 // Now is the clock the cache ages entries against. Nil means time.Now.
266 //
267 // It does not move the expiry check of step 1: auth.DecodeBearerToken reads
268 // the real clock itself and this package cannot reach inside it. A test that
269 // wants an expired token has to mint one that is genuinely in the past.
270 Now func() time.Time
271 }
272
273 // Validator resolves meta.sr.ht personal access tokens for one service. It is
274 // safe for concurrent use, which it has to be: a service holds exactly one and
275 // every request handler goes through it.
276 type Validator struct {
277 service string
278 backend Backend
279 ttl time.Duration
280 now func() time.Time
281
282 mu sync.Mutex
283 cache map[[64]byte]entry
284 }
285
286 // entry is one cached resolution: the caller it produced, and when that stops
287 // being reusable.
288 //
289 // Only successes are cached. A failure to reach meta is not an answer, and
290 // caching it would let one blip pin every token checked during it to failure for
291 // the whole TTL — turning a moment of unavailability into a minute of it, while
292 // meta is already healthy again. A genuine refusal is not cached either: it
293 // costs one local HMAC to reproduce, and the alternative is a data structure
294 // that an attacker can grow by presenting garbage.
295 type entry struct {
296 ac *auth.AuthContext
297 until time.Time
298 }
299
300 // New builds a Validator, refusing options that would only fail later.
301 27 func New(opts Options) (*Validator, error) {
302 27 if opts.Service == "" {
303 1 return nil, errors.New(
304 1 "metapat: Service is required, e.g. cov.sr.ht: decoding a grant string needs it")
305 1 }
306 26 if opts.CacheTTL < 0 {
307 1 return nil, fmt.Errorf("metapat: CacheTTL %s is negative; zero means %s",
308 1 opts.CacheTTL, DefaultCacheTTL)
309 1 }
310
311 25 v := &Validator{
312 25 service: opts.Service,
313 25 backend: opts.Backend,
314 25 ttl: opts.CacheTTL,
315 25 now: opts.Now,
316 25 cache: make(map[[64]byte]entry),
317 25 }
318 25 if v.backend == nil {
319 1 v.backend = CoreBackend()
320 1 }
321 25 if v.ttl == 0 {
322 24 v.ttl = DefaultCacheTTL
323 24 }
324 25 if v.now == nil {
325 24 v.now = time.Now
326 24 }
327 25 return v, nil
328 }
329
330 // Resolve runs the four steps against one presented personal access token.
331 //
332 // presented is the bare credential, with any "Bearer " scheme already stripped.
333 //
334 // On success it returns an *auth.AuthContext with AuthMethod, BearerToken,
335 // TokenHash and Grants filled in — the same shape core-go's own OAuth2
336 // middleware produces, so that everything downstream which already understands
337 // an OAuth2 caller keeps working, Allows included.
338 //
339 // On failure it returns one of this package's sentinels, wrapped with detail:
340 // test with errors.Is and map to a status with the table on those sentinels. The
341 // returned context is nil for every failure, including ErrNotOurs — a service
342 // that meant to accept a working token must route with PlaneOf and call its
343 // working-token validator, which is the only thing that can check one.
344 //
345 // # What is not checked here
346 //
347 // The token's own username is taken as the identity. There is no second name in
348 // a bearer header to compare it against — that check belongs to the Basic-auth
349 // flows, where a token is presented as somebody's password and the point is to
350 // stop it being presented as somebody else's.
351 //
352 // The OAuth scope is not checked either. See Allows.
353 4160 func (v *Validator) Resolve(ctx context.Context, presented string) (*auth.AuthContext, error) {
354 4160 if presented == "" {
355 1 return nil, fmt.Errorf("%w: no token presented", ErrInvalid)
356 1 }
357
358 4159 hash := sha512.Sum512([]byte(presented))
359 4159 if ac, ok := v.cached(hash); ok {
360 36 return ac, nil
361 36 }
362
363 // Step 1, local: signature and expiry. A forged or expired credential costs
364 // one HMAC and never becomes a request to meta.sr.ht.
365 4123 bt := auth.DecodeBearerToken(presented)
366 4123 if bt == nil {
367 2 return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalid)
368 2 }
369
370 // Step 2. Refused rather than attempted: a working token's grant string is
371 // in tokens.sr.ht's vocabulary, which auth.DecodeGrants would reject as
372 // malformed, and its revocation row lives at a different daemon entirely.
373 4121 if bt.ClientID == bearer.TokensClientID {
374 1 return nil, fmt.Errorf("%w: ClientID is %q", ErrNotOurs, bt.ClientID)
375 1 }
376
377 // Step 3. The token names a meta.sr.ht account; turning that into a local
378 // row is core-go's job, through the same call every other plane makes.
379 4120 var ac auth.AuthContext
380 4120 if err := v.backend.LookupUser(ctx, bt.Username, &ac); err != nil {
381 2 // Transient. The credential is good, and telling an agent to re-mint
382 2 // over a lookup outage is the wrong instruction twice: it does not help,
383 2 // and it destroys a working credential.
384 2 return nil, fmt.Errorf("%w: looking up user %q: %w", ErrUnavailable, bt.Username, err)
385 2 }
386 4118 if ac.UserID == 0 {
387 1 // LookupUser answered without filling in an id. Nothing downstream can
388 1 // use that: every ownership row keys on the user id, and a zero would
389 1 // match whichever row has an unset owner. Permanent rather than
390 1 // transient — retrying will not conjure the account back.
391 1 return nil, fmt.Errorf("%w: token names %q, for whom no meta id was mirrored",
392 1 ErrInvalid, bt.Username)
393 1 }
394
395 // Step 4.
396 4117 revoked, err := v.backend.IsRevoked(ctx, bt.Username, hash, bt.ClientID)
397 4117 if err != nil {
398 1 return nil, fmt.Errorf("%w: checking revocation for %q: %w", ErrUnavailable, bt.Username, err)
399 1 }
400 4116 if revoked {
401 1 return nil, fmt.Errorf("%w: token of %q", ErrRevoked, bt.Username)
402 1 }
403
404 4115 grants, err := auth.DecodeGrants(v.grantContext(ctx), bt.Grants)
405 4115 if err != nil {
406 1 return nil, fmt.Errorf("%w: decoding token grants: %w", ErrInvalid, err)
407 1 }
408
409 4114 ac.AuthMethod = auth.AUTH_OAUTH2
410 4114 ac.BearerToken = bt
411 4114 ac.TokenHash = hash
412 4114 ac.Grants = grants
413 4114
414 4114 v.remember(hash, &ac)
415 4114 return copyOf(&ac), nil
416 }
417
418 // grantContext derives the context auth.DecodeGrants insists on: one naming the
419 // calling service, which it uses to expand a grant written without a service
420 // prefix, and which config.ServiceName panics for the absence of.
421 //
422 // The config half is deliberately empty. DecodeGrants reads only the name, the
423 // derived context never leaves this call, and carrying a real ini.File through
424 // Options just to satisfy a field nothing reads would make every caller supply
425 // one. If core-go ever starts reading the config here, this is where it stops
426 // being enough — which is why it is one named function and not an inline
427 // expression.
428 //
429 // Overwriting rather than inspecting is forced: both context keys are
430 // unexported, so there is no way to ask whether a name is already present that
431 // does not go through the function that panics. Overwriting is also correct —
432 // what would already be there is this same service's name, put there by
433 // config.Middleware on the request path.
434 4115 func (v *Validator) grantContext(ctx context.Context) context.Context {
435 4115 return config.Context(ctx, ini.File{}, v.service)
436 4115 }
437
438 // Forget drops any cached resolution of this token, so that the next Resolve
439 // asks meta.sr.ht again.
440 //
441 // It exists for the service that learns out of band — from a webhook, from its
442 // own revocation UI — that a credential has changed, and would otherwise keep
443 // honouring it for up to CacheTTL. Forgetting a token that was never cached is a
444 // no-op rather than an error.
445 2 func (v *Validator) Forget(presented string) {
446 2 hash := sha512.Sum512([]byte(presented))
447 2 v.mu.Lock()
448 2 defer v.mu.Unlock()
449 2 delete(v.cache, hash)
450 2 }
451
452 // cached returns a live cached resolution, if there is one.
453 4159 func (v *Validator) cached(hash [64]byte) (*auth.AuthContext, bool) {
454 4159 v.mu.Lock()
455 4159 defer v.mu.Unlock()
456 4159
457 4159 e, ok := v.cache[hash]
458 4159 if !ok {
459 4122 return nil, false
460 4122 }
461 37 if !v.now().Before(e.until) {
462 1 delete(v.cache, hash)
463 1 return nil, false
464 1 }
465 36 return copyOf(e.ac), true
466 }
467
468 // remember caches one successful resolution.
469 //
470 // At maxCacheEntries the cache is dropped whole rather than evicted by age. The
471 // bound is not a tuning knob and reaching it is not the steady state: a service
472 // sees a handful of distinct credentials, and four thousand of them means either
473 // an instance far larger than this one or a caller minting a token per request.
474 // Dropping everything costs one round of re-resolution and cannot degrade into
475 // the thing an LRU can — a cache that spends more time evicting than answering,
476 // under exactly the load that filled it.
477 4114 func (v *Validator) remember(hash [64]byte, ac *auth.AuthContext) {
478 4114 v.mu.Lock()
479 4114 defer v.mu.Unlock()
480 4114
481 4114 if len(v.cache) >= maxCacheEntries {
482 1 v.cache = make(map[[64]byte]entry, maxCacheEntries)
483 1 }
484 4114 v.cache[hash] = entry{ac: copyOf(ac), until: v.now().Add(v.ttl)}
485 }
486
487 // copyOf returns a shallow copy, so that a caller which annotates the context it
488 // was handed — core-go's own middleware sets IPAddress on one — does not write
489 // through into the cache and hand the next caller somebody else's address.
490 //
491 // Shallow is enough and deep would be wrong. The pointer fields are the mirrored
492 // profile and the decoded token, which are read-only facts about the account and
493 // the credential; auth.Grants holds a map, and its only methods read it.
494 8265 func copyOf(ac *auth.AuthContext) *auth.AuthContext {
495 8265 if ac == nil {
496 1 return nil
497 1 }
498 8264 c := *ac
499 8264 return &c
500 }
501
502 // Allows reports whether a resolved caller's OAuth grants permit acting on scope
503 // at mode — the gate that complements whatever the service's own access matrix
504 // decides. A caller must pass both.
505 //
506 // scope is the full grant name as meta.sr.ht spells it, service included:
507 // "cov.sr.ht/REPORTS". The service part is not optional in practice even though
508 // core-go will fill it in from the ambient config when it is missing, because
509 // what it fills in is the *calling* service's name read off a context — which is
510 // right in a service talking about itself and silently wrong everywhere else,
511 // including in a test. Spell it out.
512 //
513 // mode is auth.RO or auth.RW; core-go panics on anything else.
514 //
515 // A caller carrying no OAuth grants at all passes unconditionally, and that is
516 // not a hole in either of the two ways it happens:
517 //
518 // - A cookie session, an anonymous request, or a tokens.sr.ht working token
519 // resolved by the other plane has no BearerToken. It was never scoped by
520 // meta's vocabulary and cannot be judged in it; a working token is scoped by
521 // its own grants, asked for separately.
522 // - A personal access token minted with no grants selected is universal by
523 // core-go's definition (auth.Grants.HasAll), exactly as it is for every
524 // upstream service on the instance.
525 13 func Allows(ac *auth.AuthContext, scope, mode string) bool {
526 13 if ac == nil || ac.BearerToken == nil {
527 3 return true
528 3 }
529 10 return ac.Grants.Has(scope, mode)
530 }
531
532 // Scope assembles the grant name of one scope on one service — Scope("cov.sr.ht",
533 // "REPORTS") is "cov.sr.ht/REPORTS".
534 //
535 // It exists so that the two spellings a service must keep in agreement are built
536 // from the same halves: the scope it publishes in api-meta.json, which meta.sr.ht
537 // turns into a checkbox by prefixing the service name itself, and the grant name
538 // it checks here. A service should assert them equal in a test rather than hope.
539 1 func Scope(service, scope string) string {
540 1 return service + "/" + scope
541 1 }
542
543 // ScopeName returns the bare scope of a full grant name — the half a service
544 // publishes in api-meta.json. ScopeName("cov.sr.ht/REPORTS") is "REPORTS".
545 //
546 // A name with no service prefix is returned unchanged, which is what makes this
547 // safe to apply to a value that may already be bare.
548 3 func ScopeName(scope string) string {
549 3 if _, after, ok := strings.Cut(scope, "/"); ok {
550 2 return after
551 2 }
552 1 return scope
553 }