Skip to content

Refuse an expired session when loading it, for every store (P1-10) - #8

Merged
eliasjpr merged 3 commits into
masterfrom
claude/azu-crystal-framework-plan-phqt6e-session-expiry
Oct 27, 2026
Merged

eliasjpr merged 3 commits into
masterfrom
claude/azu-crystal-framework-plan-phqt6e-session-expiry

Conversation

@eliasjpr

@eliasjpr eliasjpr commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What

  • Store#load_from adopts a session only while session.valid?. An expired one is ignored and the request starts a fresh session.
  • expires_at is required when a session is deserialised (JSON::Field(presence: true) plus after_initialize): a signed or stored payload without it fails to load, so no store adopts it, instead of the property default giving it a fresh expiry. Payloads written by released versions always carry the field; the serialised format is unchanged (no expires_at_present key). A subclass that overrides after_initialize must call super.
  • RedisStore#[]? deletes an expired session it reads, as MemoryStore already did.

Why (gate-2 audit P1-10)

A cookie's Expires attribute is only advice to the browser. A copied CookieStore pair carries its own signed expires_at, and nothing read it on the way in, so a replayed cookie kept a visitor signed in long after the timeout.

I reproduced this against azu master (0.8.1) and session master with require "session/azu": timeout = 2.seconds, then the same cookies replayed 3 s later still answered context.session.user_id == 42. With this change they get a new, empty session.

The check sits in load_from, so it covers every store (MemoryStore already refused expired entries; RedisStore did not look at expires_at either).

Tests

  • spec/store_spec.cr: an expired, correctly signed cookie session is not loaded (red before the fix), a live one still is; a signed cookie payload without expires_at is not loaded (red before the follow-up); with Redis (REDIS_AVAILABLE), an expired session still present in Redis is not loaded and its key is deleted (the delete assertion was red before the follow-up), and a live one still loads.
  • spec/base_spec.cr: deserialising without expires_at raises JSON::SerializableError; the serialised payload still round-trips.
  • crystal spec with a local Redis: 528 examples, 0 failures (Crystal 1.22.1). crystal tool format --check src spec scripts examples: clean. scripts/check_docs.cr (README, UPGRADE, docs): 157 snippets, 0 failed.

Behaviour change

This is a user-visible behaviour change, documented in UPGRADE.md:

  • A RedisStore session now ends at expires_at (creation, or the last regeneration, plus timeout) even while the visitor keeps using it, when sliding_expiration is false (the default). Before, every save ran SETEX, which reset the Redis TTL, and nothing read expires_at, so an active session lived indefinitely. config.sliding_expiration = true keeps active sessions alive (each request moves expires_at to timeout from now).
  • A replayed CookieStore cookie past its expires_at now gets a fresh, empty session.
  • A stored or signed payload without expires_at no longer loads.

Not in this PR

An absolute session lifetime and server-side logout/revocation for CookieStore (a revocation counter or generation claim) are design decisions, listed as open questions.

Semver: patch (security fix; no API change, but the behaviour change above is visible to users). No minor/major release marker in the commits.


Generated by Claude Code

A cookie's Expires attribute is only advice to the browser. A copied
CookieStore pair carried its own signed expires_at, which nothing read,
so it kept a visitor signed in past the timeout. load_from now adopts a
session only while it is valid; an expired one starts a fresh session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

A signed or stored session without expires_at was given a fresh expiry by
the property default, so a payload missing the field was adopted as live.
expires_at is now required when deserialising (JSON presence plus
after_initialize): such a payload fails to load and no store adopts it.
Payloads written by released versions always carry the field, and the
serialised format is unchanged.

RedisStore#[]? now deletes an expired session it reads, as MemoryStore
does, and a Redis spec covers an expired key that is still present.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
…ires_at

Without sliding_expiration, a RedisStore session now ends timeout after it was
created even while it is in use: before, SETEX reset the Redis TTL on every
save and nothing read expires_at. sliding_expiration = true keeps the old
behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
@eliasjpr
eliasjpr merged commit 6ab9211 into master Oct 27, 2026
7 of 8 checks passed
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.

2 participants