fix(coreapi): honor exchange expires_in when caching exchanged tokens · Entire

fix(coreapi): honor exchange expires_in when caching exchanged tokens

c41ab4c→main·

toothbrush·4w ago·2 files·+72 added/-11 removed

storeToken always used the fixed 4m cachedTokenTTL and discarded the expires_in PostOAuthToken returns. A server token shorter than 4m could be served stale, producing a 401 the cache assumed impossible. Thread expires_in through and cache for min(expires_in - buffer, cachedTokenTTL), d declining to cache lifetimes shorter than the buffer.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Sessions

1edf9ab1ef2cView transcript

Changes

2

101 unmodified lines

102
103
104
105
106
107
108
109
110
111
112
164 unmodified lines

277
278
279
275
280
281
282
283
284
280
285
286
287
288
218 unmodified lines

507
508
509
505
510
511
512
513
9 unmodified lines

523
524
525
521
526
527
528
529
530
531
526
532
533
528
534
535
530
536
537
538
539
9 unmodified lines

549
550
551
546
547
552
553
554
555
556
557
552
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582

101 unmodified lines

// see the same latency as a cold cache.
const cachedTokenTTL = 4 * time.Minute

// tokenExpiryBuffer is subtracted from a server-advertised expires_in so a
// cached token is retired before it actually expires — same rationale as
// cachedTokenTTL's margin below the 5m server-side lifetime.
const tokenExpiryBuffer = 1 * time.Minute

// crossJurisErrorBody is the wire shape entire-core's auth middleware
// emits for the machine-readable 401.
type crossJurisErrorBody struct {
164 unmodified lines

if !found || subjectToken == "" {
        return resp, nil
    }
    exchanged, err := t.exchangeSubjectToken(req.Context(), hint, subjectToken)
    exchanged, expiresIn, err := t.exchangeSubjectToken(req.Context(), hint, subjectToken)
    if err != nil {
        debugf("exchange failed: %v", err)
        return resp, nil
    }
    t.storeToken(origin, exchanged)
    t.storeToken(origin, exchanged, effectiveTokenTTL(expiresIn))
    budget.triedExchange[origin] = true
    _ = resp.Body.Close()
    req.Header.Set("Authorization", "Bearer "+exchanged)
218 unmodified lines

// exchange and returns the issued access_token. Delegates the wire-
// level details (form encode, lifting client_id into Basic auth,
// response decode) to httputil.PostOAuthToken.
func (t *crossJurisRoundTripper) exchangeSubjectToken(ctx context.Context, hint crossJurisErrorBody, subjectToken string) (string, error) {
func (t *crossJurisRoundTripper) exchangeSubjectToken(ctx context.Context, hint crossJurisErrorBody, subjectToken string) (string, int, error) {
    form := url.Values{}
    form.Set("grant_type", httputil.GrantTypeTokenExchange)
    form.Set("subject_token", subjectToken)
9 unmodified lines

form.Set("client_id", "entire-cli")
    // PostOAuthToken expects a base core URL and appends /oauth/token
    // itself; hint.TokenExchangeURL is already the full path (and
    // validateExchangeURL has gated the origin), so strip the suffix.
    // validateExchangeURL has gated both origin and path == oauthTokenPath),
    // so stripping the suffix yields exactly the origin.
    coreURL := strings.TrimSuffix(hint.TokenExchangeURL, oauthTokenPath)
    // Exchange goes through the base transport so we don't re-enter our
    // own retry logic — a non-200 here is terminal.
    client := &http.Client{Transport: t.base}
    token, _, err := httputil.PostOAuthToken(ctx, client, coreURL, form)
    token, expiresIn, err := httputil.PostOAuthToken(ctx, client, coreURL, form)
    if err != nil {
        return "", fmt.Errorf("exchange: %w", err)
        return "", 0, fmt.Errorf("exchange: %w", err)
    }
    return token, nil
    return token, expiresIn, nil
}

func (t *crossJurisRoundTripper) lookupToken(origin string) (string, bool) {
9 unmodified lines

return cached.token, true
}

func (t *crossJurisRoundTripper) storeToken(origin, token string) {
    if origin == "" || token == "" {
    func (t *crossJurisRoundTripper) storeToken(origin, token string, ttl time.Duration) {
    if origin == "" || token == "" || ttl <= 0 {
        return
    }
    t.tokens.Store(origin, cachedExchangedToken{
        token: token,
        exp:   time.Now().Add(cachedTokenTTL),
        exp:   time.Now().Add(ttl),
    })
}

// effectiveTokenTTL picks how long an exchanged token may live in the
// per-origin cache. It honors the exchange response's expires_in (less a
// safety buffer so we never serve a token within seconds of expiry),
// capped at cachedTokenTTL. A non-positive expires_in means the server
// advertised no lifetime, so we fall back to the conservative cap; a
// lifetime shorter than the buffer yields <=0, which storeToken declines
// to cache (the triggering request still succeeds via the live retry).
func effectiveTokenTTL(expiresIn int) time.Duration {
    if expiresIn <= 0 {
        return cachedTokenTTL
    }
    tl := time.Duration(expiresIn)*time.Second - tokenExpiryBuffer
    if ttl > cachedTokenTTL {
        return cachedTokenTTL
    }
    return ttl
}

// bufferBody reads req.Body once into memory and sets GetBody so each
// retry produces a fresh reader. Returns nil when there's no body.
func bufferBody(req *http.Request) ([]byte, error) {

Minternal/coreapi/cross_juris_transport.go+34/-10


420 unmodified lines

421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
202 unmodified lines

666
667
668
632
669
670
671
672

420 unmodified lines

}
}

func TestEffectiveTokenTTL(t *testing.T) {
    t.Parallel()
    // No advertised lifetime → conservative cap.
    if got := effectiveTokenTTL(0); got != cachedTokenTTL {
            .Errorf("expires_in=0: got %v, want %v", got, cachedTokenTTL)
    }
    if got := effectiveTokenTTL(-5); got != cachedTokenTTL {
            .Errorf("expires_in<0: got %v, want %v", got, cachedTokenTTL)
    }
    // Long-lived token is capped at cachedTokenTTL.
    if got := effectiveTokenTTL(3600); got != cachedTokenTTL {
            .Errorf("long lifetime: got %v, want cap %v", got, cachedTokenTTL)
    }
    // Short-lived token honors expires_in minus the buffer.
    if got := effectiveTokenTTL(180); got != 180*time.Second-tokenExpiryBuffer {
            .Errorf("short lifetime: got %v, want %v", got, 180*time.Second-tokenExpiryBuffer)
    }
    // Lifetime under the buffer yields <=0, which storeToken declines to cache.
    if got := effectiveTokenTTL(30); got > 0 {
            .Errorf("sub-buffer lifetime: got %v, want <=0", got)
    }
}

// TestStoreTokenDeclinesNonPositiveTTL: a <=0 TTL must not be cached.
func TestStoreTokenDeclinesNonPositiveTTL(t *testing.T) {
    t.Parallel()
    rt := transportFor()
    rt.storeToken("https://example.test", "tok", 0)
    if _, ok := rt.lookupToken("https://example.test"); ok {
            .Error("zero TTL must not be cached")
    }
    rt.storeToken("https://example.test", "tok", -time.Second)
    if _, ok := rt.lookupToken("https://example.test"); ok {
            .Error("negative TTL must not be cached")
    }
}

// TestRoundTripper_TokenCacheReusesExchanged confirms the per-origin
// cache: a second request to the same origin within TTL skips the
// exchange and presents the cached token on the first attempt.
202 unmodified lines

func TestCacheExpiresAfterTTL(t *testing.T) {
    t.Parallel()
    rt := transportFor()
    rt.storeToken("https://example.test", "tok")
    rt.storeToken("https://example.test", "tok", cachedTokenTTL)
    if _, ok := rt.lookupToken("https://example.test"); !ok {
            .Fatal("fresh token must be a hit")
    }