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

Coverage
93.7% 89/95 statements
Δ
+0.0
Blob
7749089
1 // Package bearer validates a tokens.sr.ht working token, and is the one copy of
2 // that check for every service on the instance (SPEC ch. 6).
3 //
4 // A working token is an auth.BearerToken: the version, expiry, grants, client id
5 // and owner in BARE, HMAC-SHA256 over the lot, base64. It is the same format and
6 // the same key meta.sr.ht stamps its own personal access tokens with, so a
7 // service needs no new cryptographic stack to accept one — and, for the same
8 // reason, the signature alone does not say who issued the token. Only the client
9 // id does, which is why step 2 below exists at all.
10 //
11 // The four steps, in order, are SPEC ch. 6:
12 //
13 // 1. decode and verify (local, no network);
14 // 2. is this token ours? — if not, the *service's* policy decides;
15 // 3. does it grant the action being attempted?
16 // 4. if it is a registered token, is it still live? — the one step that talks
17 // to tokens.sr.ht, cached, and skipped entirely by a short token.
18 //
19 // The order is not an implementation detail. Every step that can refuse locally
20 // runs before the one that cannot, so the network is touched only for a token
21 // that has already proved it is well-formed, ours, unexpired and sufficient for
22 // what its holder is doing. A forged or expired credential never reaches the
23 // daemon, and an instance under a flood of junk tokens does not turn that flood
24 // into traffic against tokens.sr.ht.
25 //
26 // Usage:
27 //
28 // v, err := bearer.New(bearer.Options{
29 // Origin: conf.Get("tokens.sr.ht", "origin"),
30 // ClientID: "bench.sr.ht",
31 // NodeID: hostname,
32 // })
33 // ...
34 // tok, err := v.Validate(r.Context(), presented, "bench:upload")
35 //
36 // The process must have run crypto.InitCrypto before any of this: the signing
37 // key of step 1 and the network key that seals the internal authorization of
38 // step 4 both live in that package's globals. This is the same precondition
39 // every core-go authentication path carries and it is not checked here, because
40 // there is nothing this package could usefully do about it at request time.
41 //
42 // Resolving the owner is not this package's job. The token names a meta.sr.ht
43 // username; turning that into a local row is auth.LookupUser plus whatever the
44 // service's own plan does with a user it has never seen (SPEC ch. 6), and that
45 // differs per service in ways a shared validator should not have opinions about.
46 package bearer
47
48 import (
49 "context"
50 "errors"
51 "fmt"
52 "io"
53 "net/http"
54 "net/url"
55 "strconv"
56 "strings"
57 "sync"
58 "time"
59
60 "sourcecraft.dev/bigbes/sr-ht-core/auth"
61
62 "sourcecraft.dev/bigbes/sr-ht-ecore/grants"
63 "sourcecraft.dev/bigbes/sr-ht-ecore/internalauth"
64 )
65
66 // TokensClientID is the ClientID tokens.sr.ht stamps into every working token it
67 // seals, and the only thing that distinguishes one from a meta.sr.ht PAT: the
68 // two share a signing key, so a valid signature says the instance made the
69 // token and not which part of it did (SPEC ch. 1).
70 const TokensClientID = "tokens.sr.ht"
71
72 // DefaultCacheTTL is how long a revocation answer is reused when Options leaves
73 // CacheTTL at zero. Sixty seconds, the figure SPEC ch. 6 step 4 names.
74 const DefaultCacheTTL = 60 * time.Second
75
76 // defaultHTTPTimeout bounds a revocation check when the caller supplies no
77 // client of its own.
78 //
79 // It has to be short. This request sits on the hot path of an upload, behind a
80 // context the caller may not have given a deadline to, and the endpoint it calls
81 // answers from one indexed row — a healthy daemon replies in single-digit
82 // milliseconds. Five seconds is generous for that and still short enough that a
83 // hung tokens.sr.ht turns into 503s rather than into request-handler goroutines
84 // piling up across every service on the instance.
85 const defaultHTTPTimeout = 5 * time.Second
86
87 // revocationPath is the endpoint of SPEC ch. 5, joined to Origin.
88 const revocationPath = "/api/v1/revocations/"
89
90 // maxCacheEntries bounds the revocation cache. See (*Validator).remember for
91 // what happens at the bound and why that is the right thing to happen.
92 const maxCacheEntries = 4096
93
94 // The refusals of SPEC ch. 6, and the status each one is for a service.
95 //
96 // They are separate sentinels rather than one error with a code because the
97 // mapping is not uniform, and the interesting cases are the two that are not
98 // 401:
99 //
100 // - ErrInvalid — 401. The signature did not verify, the version is foreign, or
101 // the token has expired.
102 // - ErrNotOurs — the service's own policy, not a status. See Validate.
103 // - ErrForbidden — 403. The credential is good; it does not cover this action.
104 // Distinct from 401 because retrying with the same token is pointless and
105 // the holder needs to be told to ask for a wider grant, not to log in again.
106 // - ErrRevoked — 401. The credential was withdrawn. Deliberately not 403: the
107 // token is no longer a credential at all, and a client that sees 403 will
108 // keep presenting it.
109 // - ErrUnavailable — 503. tokens.sr.ht could not be asked.
110 //
111 // The 503 is the one that has to be defended, because "I could not check" reads
112 // so naturally as "so I will not accept it". Reading an unreachable daemon as a
113 // revocation would refuse every registered token on the instance for as long as
114 // tokens.sr.ht is down — turning a restart of a service that is deliberately off
115 // the hot path into an instance-wide outage of uploads. SPEC ch. 6 step 4 says
116 // 503 for exactly that reason, following core-go, which answers the same way
117 // when meta.sr.ht cannot be reached. 503 also says the true thing to a client:
118 // come back, this is us and it is temporary.
119 //
120 // This is not a decision to fail open. A short token was never checked against
121 // the daemon in the first place, and a registered one whose revocation cannot be
122 // confirmed is refused — with a status that keeps the operator's attention on
123 // the daemon instead of on a thousand clients being told their credentials are
124 // bad.
125 var (
126 // ErrInvalid: the presented string is not a token this instance sealed, or
127 // no longer is one. 401.
128 ErrInvalid = errors.New("bearer: token does not verify")
129
130 // ErrNotOurs: a well-formed token from another issuer, almost certainly a
131 // meta.sr.ht PAT. Returned together with the decoded token; the status is
132 // the service's to choose.
133 ErrNotOurs = errors.New("bearer: token was not issued by tokens.sr.ht")
134
135 // ErrForbidden: our token, valid, but it does not carry the action. 403.
136 ErrForbidden = errors.New("bearer: token does not grant this action")
137
138 // ErrRevoked: a registered token whose row is gone, revoked or expired. 401.
139 ErrRevoked = errors.New("bearer: token has been revoked")
140
141 // ErrUnavailable: the revocation check could not be completed. 503, never
142 // 401 — see above.
143 ErrUnavailable = errors.New("bearer: tokens.sr.ht could not be reached")
144 )
145
146 // Options configures a Validator.
147 type Options struct {
148 // Origin is where tokens.sr.ht answers, scheme and host, e.g.
149 // "https://tokens.srht.bigb.es". Required.
150 Origin string
151
152 // ClientID identifies the *calling* service in the internal authorization of
153 // step 4, e.g. "bench.sr.ht". Required — the daemon's guard refuses a blob
154 // that names neither this nor NodeID.
155 //
156 // It is a label, not a credential: the guard admits every internal service
157 // equally and this only decides what its log line says. Which is precisely
158 // why it should be right; it is what an operator has to go on when the
159 // revocation cache misbehaves.
160 ClientID string
161
162 // NodeID identifies the calling process or host, e.g. "bench-1". Required,
163 // for the same reason and with the same weight as ClientID.
164 NodeID string
165
166 // HTTPClient performs the revocation check. Nil means a client with
167 // defaultHTTPTimeout.
168 HTTPClient *http.Client
169
170 // CacheTTL is how long one revocation answer is reused. Zero means
171 // DefaultCacheTTL; negative is refused.
172 CacheTTL time.Duration
173
174 // Now is the clock the cache ages entries against. Nil means time.Now.
175 //
176 // It does not move the expiry check of step 1: auth.DecodeBearerToken reads
177 // the real clock itself and this package cannot reach inside it. A test that
178 // wants an expired token has to mint one that is genuinely in the past.
179 Now func() time.Time
180 }
181
182 // Validator runs the check of SPEC ch. 6 for one calling service. It is safe for
183 // concurrent use, which it has to be: a service holds exactly one and every
184 // request handler goes through it.
185 type Validator struct {
186 origin string
187 clientID string
188 nodeID string
189 client *http.Client
190 ttl time.Duration
191 now func() time.Time
192
193 mu sync.Mutex
194 cache map[int]verdict
195 }
196
197 // verdict is one cached answer from the revocation endpoint: whether the row was
198 // live, and when that answer stops being reusable.
199 //
200 // ErrUnavailable never becomes a verdict — a failure to ask is not an answer,
201 // and caching it would let one blip, one timeout, one restart pin every token
202 // that happened to be checked during it to failure for the whole TTL. That turns
203 // a moment of unavailability into a minute of it, and it does so silently,
204 // because the daemon is healthy again while the services are still refusing.
205 type verdict struct {
206 alive bool
207 until time.Time
208 }
209
210 // Token is what validation yields: who the holder is, what the token permits,
211 // and how long it lasts.
212 type Token struct {
213 // Username is the meta.sr.ht account the token was issued to. Resolving it
214 // to a local row is the service's job (auth.LookupUser).
215 Username string
216
217 // Grants is the parsed grant set.
218 //
219 // It is the zero value — which admits nothing — when the error is
220 // ErrNotOurs, and that is not an oversight. A foreign token's grant string is
221 // in whatever vocabulary its issuer uses, and meta.sr.ht's is core-go's
222 // auth.Grants ("git.sr.ht/OBJECTS:RW"), a different grammar that this one
223 // would reject as malformed. A service that accepts meta PATs must decode
224 // that string with auth.DecodeGrants; it can get at it by calling
225 // auth.DecodeBearerToken on the presented string, which is local and cheap.
226 Grants grants.Grants
227
228 // TokenID is the row id from the grant string's id: member, or 0 for a
229 // stateless token — one short enough that tokens.sr.ht never wrote it down
230 // and that therefore has no revocation to check (SPEC ch. 2).
231 TokenID int
232
233 // Expires is when the signature stops being honoured.
234 Expires time.Time
235 }
236
237 // Registered reports whether this token has a row at tokens.sr.ht — the
238 // difference between a credential its owner can revoke and one that can only be
239 // waited out.
240 3 func (t *Token) Registered() bool { return t.TokenID != 0 }
241
242 // Authorize is step 3 of SPEC ch. 6, split out so that it can be asked where the
243 // action is known — in the handler that implements it — rather than in the
244 // middleware that resolved the identity. See Inspect.
245 //
246 // It reports ErrForbidden and not ErrInvalid: the credential is good and the
247 // caller is who they say they are, and what is missing is a permission. That
248 // distinction is what stops an agent retrying forever with a token that will
249 // never grow the grant it needs.
250 285 func (t *Token) Authorize(action string) error {
251 285 if !t.Grants.Has(action) {
252 2 return fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, t.Grants.String())
253 2 }
254 283 return nil
255 }
256
257 // New builds a Validator, refusing options that would only fail later, one
258 // request at a time, as an error about the network.
259 31 func New(opts Options) (*Validator, error) {
260 31 if opts.Origin == "" {
261 1 return nil, errors.New("bearer: Origin is required, e.g. https://tokens.srht.bigb.es")
262 1 }
263 30 u, err := url.Parse(opts.Origin)
264 30 if err != nil {
265 1 return nil, fmt.Errorf("bearer: Origin %q does not parse: %w", opts.Origin, err)
266 1 }
267 // An origin without a scheme and host is not one, and the failure it causes
268 // otherwise is a request error on the first registered token some service
269 // sees — days after the config was written, and reported as ErrUnavailable,
270 // which points the operator at the daemon rather than at the typo.
271 29 if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
272 3 return nil, fmt.Errorf(
273 3 "bearer: Origin %q must be an absolute http(s) URL, e.g. https://tokens.srht.bigb.es",
274 3 opts.Origin)
275 3 }
276 26 if opts.ClientID == "" {
277 1 return nil, errors.New("bearer: ClientID is required: the internal guard refuses a blob without one")
278 1 }
279 25 if opts.NodeID == "" {
280 1 return nil, errors.New("bearer: NodeID is required: the internal guard refuses a blob without one")
281 1 }
282 24 if opts.CacheTTL < 0 {
283 1 return nil, fmt.Errorf("bearer: CacheTTL %s is negative; zero means %s", opts.CacheTTL, DefaultCacheTTL)
284 1 }
285
286 23 v := &Validator{
287 23 origin: strings.TrimSuffix(opts.Origin, "/"),
288 23 clientID: opts.ClientID,
289 23 nodeID: opts.NodeID,
290 23 client: opts.HTTPClient,
291 23 ttl: opts.CacheTTL,
292 23 now: opts.Now,
293 23 cache: make(map[int]verdict),
294 23 }
295 23 if v.client == nil {
296 23 v.client = &http.Client{Timeout: defaultHTTPTimeout}
297 23 }
298 23 if v.ttl == 0 {
299 23 v.ttl = DefaultCacheTTL
300 23 }
301 23 if v.now == nil {
302 18 v.now = time.Now
303 18 }
304 23 return v, nil
305 }
306
307 // Validate runs the four steps of SPEC ch. 6 against one presented token for one
308 // action, e.g. "bench:upload".
309 //
310 // presented is the bare credential, with any "Bearer " scheme already stripped.
311 //
312 // On success it returns the token and a nil error. On failure it returns one of
313 // this package's sentinels, wrapped with detail — test with errors.Is, and map
314 // it to a status with the table on those sentinels.
315 //
316 // The returned token is nil for every failure except ErrNotOurs. A token that
317 // failed validation is not a token, and handing one back invites a caller to use
318 // what it was just told not to; the exception is the whole point of step 2,
319 // where the token *did* validate and only the question of whose it is remains.
320 //
321 // # Step 2 is not decided here
322 //
323 // A token whose ClientID is not TokensClientID gets ErrNotOurs and the decoded
324 // token, and this package takes it no further. That is deliberate and SPEC ch. 6
325 // step 2 requires it: what to do with a foreign bearer token is per-service
326 // policy, not a property of the format, and the policy varies by *surface* and
327 // not only by service. Every GraphQL endpoint on the instance accepts meta PATs,
328 // because api.sr.ht forwards one client credential to every service a federated
329 // query touches, so one that refused them could not be federated at all; the
330 // REST and MCP surfaces beside them accept only working tokens, where a narrow,
331 // revocable grant is worth its cost. A validator that refused on its own behalf
332 // would break the first kind and look correct doing it, because the token really
333 // is not one of ours — it just is not this package's call.
334 //
335 // So a service that also accepts meta PATs writes:
336 //
337 // tok, err := v.Validate(ctx, presented, "dolt:push")
338 // if errors.Is(err, bearer.ErrNotOurs) {
339 // // ... its own meta-PAT path — see the metapat package ...
340 // }
341 //
342 // and one that does not turns ErrNotOurs into 401 alongside ErrInvalid.
343 //
344 // Routing on this error is the second-best way to do it, and metapat.PlaneOf is
345 // the first: an instance with no [tokens.sr.ht] section holds no Validator to
346 // call, and its meta PAT plane has to keep working anyway.
347 290 func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) {
348 290 tok, err := decodeOurs(presented)
349 290 if err != nil {
350 7 return tok, err
351 7 }
352 // Step 3 before step 4, deliberately: a token that does not carry the grant
353 // is refused without a round trip to the daemon. Inspect cannot keep this
354 // ordering — it has no action to check — which is the one cost of the split
355 // and the reason this method still exists for callers that know both.
356 283 if err := tok.Authorize(action); err != nil {
357 1 return nil, err
358 1 }
359 282 if err := v.revoked(ctx, tok); err != nil {
360 8 return nil, err
361 8 }
362 274 return tok, nil
363 }
364
365 // Inspect is Validate without step 3: it answers who a token belongs to, what it
366 // may do, and whether it is still live — and leaves the question of whether that
367 // covers *this* action to the caller.
368 //
369 // It exists because of where the two questions get answered in a sourcehut
370 // service. Identity is resolved once per request in middleware, upstream of the
371 // router: that is where the cookie plane and the bearer plane meet and where a
372 // principal is put on the context, and at that point nothing knows yet which
373 // route will run, so nothing knows the action. The action is known one layer
374 // down, in the handler that implements it. A validator that insisted on both at
375 // once would force every service either to invent an action before it has one,
376 // or to lift its bearer plane out of the middleware every other plane goes
377 // through — and the second is how a surface ends up with two different ideas of
378 // who is calling.
379 //
380 // So: call Inspect in the resolver and carry the Grants on the principal, then
381 // call Token.Authorize in the handler. Validate stays for callers that know both
382 // at one point, and is exactly those two calls.
383 //
384 // Everything Validate's doc comment says — about the sentinels, about step 2
385 // being per-service policy, and about the returned token being nil for every
386 // failure except ErrNotOurs — applies here unchanged.
387 3 func (v *Validator) Inspect(ctx context.Context, presented string) (*Token, error) {
388 3 tok, err := decodeOurs(presented)
389 3 if err != nil {
390 1 return tok, err
391 1 }
392 2 if err := v.revoked(ctx, tok); err != nil {
393 0 return nil, err
394 0 }
395 2 return tok, nil
396 }
397
398 // revoked is step 4: a registered token has a revocation to ask about, a
399 // stateless one has no row and so completes without any network at all — which
400 // is the common case under the default configuration.
401 284 func (v *Validator) revoked(ctx context.Context, tok *Token) error {
402 284 if tok.TokenID == 0 {
403 88 return nil
404 88 }
405 196 return v.checkRevocation(ctx, tok.TokenID)
406 }
407
408 // decodeOurs is steps 1 and 2 plus the grant parse: everything that can be
409 // decided from the token itself, with no clock but the real one and no network
410 // at all.
411 293 func decodeOurs(presented string) (*Token, error) {
412 293 // Step 1. Signature, version and expiry, all of it local.
413 293 //
414 293 // DecodeBearerToken returns nil for all three and distinguishes none of
415 293 // them, which is the right amount of detail to give a client anyway. It
416 293 // checks the expiry itself, against the real clock — so an expired token
417 293 // costs one HMAC and never becomes a request to anybody. That property is
418 293 // what makes it safe for this step to run before every other.
419 293 bt := auth.DecodeBearerToken(presented)
420 293 if bt == nil {
421 6 return nil, fmt.Errorf("%w: signature, version or expiry", ErrInvalid)
422 6 }
423
424 // Step 2. Ours, or somebody else's? Not our decision — see the doc comment.
425 287 if bt.ClientID != TokensClientID {
426 2 return &Token{
427 2 Username: bt.Username,
428 2 Expires: bt.Expires.Time(),
429 2 }, fmt.Errorf("%w: ClientID is %q, not %q", ErrNotOurs, bt.ClientID, TokensClientID)
430 2 }
431
432 285 g, err := grants.Parse(bt.Grants)
433 285 if err != nil {
434 0 // Only tokens.sr.ht seals a token with our ClientID, and it writes the
435 0 // grant string with the same parser that is failing here, so this is
436 0 // either a version skew between daemon and service or a bug in one of
437 0 // them. Either way it is not a credential this service can act on.
438 0 return nil, fmt.Errorf("%w: grants %q do not parse: %s", ErrInvalid, bt.Grants, err)
439 0 }
440
441 285 tok := &Token{
442 285 Username: bt.Username,
443 285 Grants: g,
444 285 TokenID: g.TokenID(),
445 285 Expires: bt.Expires.Time(),
446 285 }
447 285
448 285 return tok, nil
449 }
450
451 // Forget drops the cached revocation answer for one token id, so that the next
452 // validation asks the daemon again.
453 //
454 // It is for the case where a service learns out of band that an answer is stale
455 // — a webhook, an operator, a test — and wants the revocation to take effect now
456 // rather than at the end of the TTL. Forgetting an id that is not cached is a
457 // no-op, and forgetting one that is only costs a round trip.
458 34 func (v *Validator) Forget(id int) {
459 34 v.mu.Lock()
460 34 delete(v.cache, id)
461 34 v.mu.Unlock()
462 34 }
463
464 // checkRevocation is step 4: ask GET {Origin}/api/v1/revocations/{id}, through
465 // the cache.
466 //
467 // 204 is live, 404 is not, and everything else — a 500, a timeout, a refused
468 // connection, a proxy's HTML error page — is ErrUnavailable. The endpoint has
469 // exactly two answers by design (SPEC ch. 5), so anything that is neither is not
470 // a third answer; it is the absence of one.
471 //
472 // Two concurrent validations of the same id will both issue a request when the
473 // entry is cold. That is a duplicated round trip and nothing worse: the answers
474 // agree, the second write to the cache is idempotent, and collapsing them would
475 // buy one saved request in exchange for a dependency and a shared failure mode
476 // where a single slow call holds up every goroutine waiting behind it.
477 196 func (v *Validator) checkRevocation(ctx context.Context, id int) error {
478 196 if alive, ok := v.cached(id); ok {
479 134 if alive {
480 134 return nil
481 134 }
482 0 return fmt.Errorf("%w: token %d (cached)", ErrRevoked, id)
483 }
484
485 // The internal authorization is minted per request and cannot be cached: it
486 // is a fernet blob the daemon accepts only for thirty seconds, which is what
487 // stops a captured one being replayed for a week.
488 //
489 // Through internalauth rather than assembled here, so that this caller and
490 // every Guard on the instance read one definition of the payload. Minting it
491 // by hand next to a package whose whole purpose is to hold both ends of this
492 // handshake is the drift that package exists to prevent.
493 62 authorization, err := internalauth.Authorization(v.clientID, v.nodeID)
494 62 if err != nil {
495 0 return fmt.Errorf("%w: sealing the internal authorization: %s", ErrUnavailable, err)
496 0 }
497
498 62 url := v.origin + revocationPath + strconv.Itoa(id)
499 62 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
500 62 if err != nil {
501 0 return fmt.Errorf("%w: building the request for %s: %s", ErrUnavailable, url, err)
502 0 }
503 62 req.Header.Set("Authorization", authorization)
504 62
505 62 resp, err := v.client.Do(req)
506 62 if err != nil {
507 2 return fmt.Errorf("%w: asking %s: %s", ErrUnavailable, url, err)
508 2 }
509 60 defer resp.Body.Close()
510 60 // Both answers are empty bodies, but read to the end anyway so the
511 60 // connection goes back to the pool instead of being dropped and redialled on
512 60 // every upload.
513 60 _, _ = io.Copy(io.Discard, resp.Body)
514 60
515 60 switch resp.StatusCode {
516 54 case http.StatusNoContent:
517 54 v.remember(id, true)
518 54 return nil
519 3 case http.StatusNotFound:
520 3 // 404 covers revoked, expired and unknown alike, and all three are
521 3 // permanent: no id ever goes back to being live. Caching it is therefore
522 3 // not a staleness risk in the way caching "live" is.
523 3 v.remember(id, false)
524 3 return fmt.Errorf("%w: token %d", ErrRevoked, id)
525 3 default:
526 3 return fmt.Errorf("%w: %s answered %s", ErrUnavailable, url, resp.Status)
527 }
528 }
529
530 // cached returns a still-valid answer for id, if there is one.
531 196 func (v *Validator) cached(id int) (alive, ok bool) {
532 196 now := v.now()
533 196
534 196 v.mu.Lock()
535 196 defer v.mu.Unlock()
536 196
537 196 e, ok := v.cache[id]
538 196 if !ok || !now.Before(e.until) {
539 62 return false, false
540 62 }
541 134 return e.alive, true
542 }
543
544 // remember stores an answer for CacheTTL, and keeps the cache bounded.
545 //
546 // The TTL is the trade SPEC ch. 6 makes on purpose and it should be stated
547 // plainly: a revocation takes up to CacheTTL to take effect across the instance.
548 // The alternative is asking the daemon on every request, which puts tokens.sr.ht
549 // back on the hot path of every upload and makes its availability the
550 // instance's — the exact coupling SPEC ch. 1 removes. Sixty seconds of a revoked
551 // token still working is the price of that, and the operator revoking it should
552 // be told to expect it.
553 //
554 // The bound is a sweep, then a drop. At maxCacheEntries the expired entries go
555 // first; if that does not get under the bound, the whole map goes. No LRU, no
556 // eviction list — every entry here is worth exactly one HTTP round trip to
557 // rebuild and they all expire within CacheTTL anyway, so the cost of throwing
558 // away a full cache is bounded and small, while the cost of a map that only ever
559 // grows is a leak in a process meant to run for months. In practice the bound
560 // never fires: an entry can only be created by a token that already passed an
561 // HMAC check, so the id space here is the daemon's real rows and not something a
562 // caller can inflate.
563 8249 func (v *Validator) remember(id int, alive bool) {
564 8249 now := v.now()
565 8249
566 8249 v.mu.Lock()
567 8249 defer v.mu.Unlock()
568 8249
569 8249 if len(v.cache) >= maxCacheEntries {
570 4096 for k, e := range v.cache {
571 4096 if !now.Before(e.until) {
572 0 delete(v.cache, k)
573 0 }
574 }
575 1 if len(v.cache) >= maxCacheEntries {
576 1 v.cache = make(map[int]verdict, maxCacheEntries)
577 1 }
578 }
579 8249 v.cache[id] = verdict{alive: alive, until: now.Add(v.ttl)}
580 }