Skip to content

fix(oauth2): compute expires_in from the server clock - #66

Merged
euskadi31 merged 1 commit into
masterfrom
feature/64-oauth2-expires-in-clock
Aug 23, 2026
Merged

fix(oauth2): compute expires_in from the server clock#66
euskadi31 merged 1 commit into
masterfrom
feature/64-oauth2-expires-in-clock

Conversation

@euskadi31

Copy link
Copy Markdown
Contributor

Closes #64.

Problem

ServerConfig.Now is documented as the clock the server stamps issuance and
expiry with, and every other site honours it. writeTokenResponse did not:

ExpiresIn: int(time.Until(resp.Pair.Access.ExpiresAt).Seconds()),

ExpiresAt comes from the injected clock (req.Now.Add(cfg.AccessTTL),
with req.Now = s.cfg.Now()), while time.Until measures against
time.Now(). Mixing the two means the advertised expires_in stops
describing the token it ships with: a clock pinned in the past yields a
value smaller than the real lifetime, and one pinned far enough in the past
yields a negative expires_in, which RFC 6749 §5.1 does not allow. The
stored expiry stays correct, so a client trusting expires_in refreshes at
the wrong moment while introspection still reports the token as valid.

The truncation the issue notes as a side effect is real too:
int(d.Seconds()) truncates toward zero, so a 1h TTL was advertised as
3599 as soon as a millisecond elapsed between the two clock reads — on
the wall clock, i.e. in production, not only under a test clock.

Fix

serveToken reads the clock once and hands that instant to both the
grant and the response:

now := s.cfg.Now()

resp, err := handler.Handle(r.Context(), GrantRequest{..., Now: now, ...})
// ...
writeTokenResponse(w, resp, now)

expires_in is then derived from the same instant the expiry was stamped
with, so it equals the configured TTL exactly. A small helper covers the
two remaining edges:

func expiresIn(expiresAt, now time.Time) int {
	d := expiresAt.Sub(now)
	if d <= 0 {
		return 0
	}

	return int(d.Round(time.Second).Seconds())
}
  • Rounding to nearest rather than truncating, so a whole-second TTL is
    advertised as itself. This also keeps sub-second TTLs honest for a
    custom grant that stamps its own expiry.
  • Never negative: an already-expired token yields 0, and the field is
    omitempty, so it drops off the wire rather than carrying a lifetime the
    RFC forbids. expires_in is RECOMMENDED, not REQUIRED, so omitting it
    stays compliant — sending -3600 does not.

The implicit flow already built expires_in from the TTL directly
(authorize_endpoint.go:439) and was never affected.

Tests

New oauth2/token_endpoint_test.go:

  • TestTokenExpiresInHonorsServerClock — clock pinned to 2020, TTL 1h:
    advertises 3600. Against the wall clock this would be a large negative
    number.
  • TestTokenExpiresInMatchesTTLOnWallClock — the default clock advertises
    the configured TTL exactly, not one second short.
  • TestTokenExpiresInRoundsToNearestSecond — whole seconds, rounds up,
    rounds down.
  • TestTokenExpiresInNeverNegative — already expired and expiring now: the
    field is absent, the rest of the body intact.

All 8 subtests fail against the previous implementation and pass with this
one (verified by reverting the helper locally).

Checks

  • make test — 26 packages pass with -race; oauth2 coverage
    90.9% -> 91.1%.
  • make lint — 0 issues.
  • make build — OK.

Also updated: the ServerConfig.Now godoc (it now says it governs
expires_in too) and the CHANGELOG.md Fixed section.

ServerConfig.Now is documented as the clock the server stamps issuance and
expiry with, and every other site honoured it. writeTokenResponse did not:
it measured the remaining lifetime with time.Until, i.e. against the wall
clock, while ExpiresAt came from the injected clock.

With a pinned clock the advertised expires_in stopped describing the token
it shipped with, and a clock pinned far enough in the past put a negative
lifetime on the wire — which RFC 6749 §5.1 does not allow. The stored expiry
was right, so a client trusting expires_in refreshed at the wrong moment
while introspection still reported the token as valid.

serveToken now reads the clock once and passes that instant to both the
grant and the response, so expires_in is exactly the configured TTL. The
value is rounded to the nearest second — truncation advertised a 1h TTL as
3599 as soon as a millisecond elapsed between two clock reads — and an
already-expired token drops the field instead of carrying a negative
lifetime. Refs #64.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 32661849110

Coverage increased (+0.02%) to 91.77%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 12 of 12 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 4265
Covered Lines: 3914
Line Coverage: 91.77%
Coverage Strength: 12.68 hits per line

💛 - Coveralls

@euskadi31
euskadi31 merged commit e253a54 into master Aug 23, 2026
2 checks passed
@euskadi31
euskadi31 deleted the feature/64-oauth2-expires-in-clock branch August 23, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

oauth2: expires_in ignores ServerConfig.Now and reads the wall clock

2 participants