Skip to content

Add last_issued_at field to SessionToken - #30

Merged
lnagel merged 12 commits into
namespace-ee:mainfrom
PaulOskarSoe:feat/last_issued_at-field
Jun 12, 2026
Merged

Add last_issued_at field to SessionToken#30
lnagel merged 12 commits into
namespace-ee:mainfrom
PaulOskarSoe:feat/last_issued_at-field

Conversation

@PaulOskarSoe

Copy link
Copy Markdown
Member

Summary

  • Adds a nullable last_issued_at DateTimeField to SessionToken, set to timezone.now() on every successful POST /authenticate/.
  • Backfills existing rows with last_issued_at = created_at via a separate non-atomic migration so the row rewrite is not bundled into the AddField transaction.
  • No index — the field is read and written by PK only; the comparison happens in Python after the row is already loaded.

Test plan

  • uv run ruff check . and uv run ruff format --check . pass
  • uv run pytest tests/ — 23 passed
  • manage.py makemigrations --check --dry-run — no further changes
  • Apply migrations on a dev DB with existing rows, verify last_issued_at == created_at for backfilled rows
  • POST /authenticate/ end-to-end: first call sets last_issued_at == created_at; subsequent calls on the same (user, client_id) advance last_issued_at while created_at stays put

🤖 Generated with Claude Code

PaulOskarSoe and others added 11 commits April 28, 2026 12:59
Tracks when a session-token JWT was last issued. Updated on every
successful POST /authenticate/. Backfilled to created_at for existing
rows via a separate non-atomic migration so the row rewrite is not
bundled into the AddField transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Combines the AddField and the backfill UPDATE into a single atomic
migration, dropping the separate non-atomic 0007 migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves iat before exp so exp = iat + EXPIRATION_TIMEDELTA exactly,
instead of computing each from independent now() calls. Truncates the
fallback iat to whole seconds so every emitted JWT carries an integer-
second iat, matching the wire format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Threads a single second-precision timestamp through both the DB column
and the JWT payload so int(last_issued_at.timestamp()) == decoded iat
exactly, enabling reliable downstream validation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the OAuth2-style refresh tracking semantics: session token
issuance updates last_issued_at to a whole-second timestamp matching
the JWT iat claim, reuse advances the field, and authorization token
issuance leaves it untouched. Encoder tests lock in caller-supplied
iat being preserved and used to derive exp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without mocking timezone.now, both the view's now() call and the
encoder's fallback now() landed in the same wall-clock second on fast
machines, so a misalignment between last_issued_at and iat went
undetected. Patching timezone.now in the view to a fixed past datetime
makes the assertion fail unambiguously when the values are not threaded
through a single source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hardcoded UPDATE rest_framework_sso_sessiontoken targets the wrong
table when downstream consumers swap in a non-default concrete model
(SessionToken.Meta.abstract is conditional on the app being installed).
RunPython + apps.get_model resolves the actual table at migration
time, and F("created_at") keeps the backfill atomic at the database
level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The view now sets session_token.last_issued_at before calling the
payload factory, so any downstream override of CREATE_SESSION_PAYLOAD
that reads last_issued_at sees the value being threaded into this
JWT, not the previous one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If encode_jwt_token raises after the SessionToken save, the column
update (last_issued_at, ip_address, user_agent) was persisted but no
token was returned to the client. Atomic rolls back the whole
issuance on failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
unittest.mock.patch was replacing the entire datetime/timezone module
in the target module's namespace, which silently turned any other
attribute access on those modules into MagicMock. time_machine.travel
patches the underlying clock, leaving normal datetime/timezone APIs
intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the OAuth2-style refresh-token-rotation loop: when a session is
re-issued, any JWT a client was holding from a prior generation
becomes invalid. authenticate_payload now compares the decoded iat
(int seconds, per PyJWT) against int(last_issued_at.timestamp()) and
raises AuthenticationFailed on a strict less-than. Equality means
"this is the current generation's token" and is accepted. Missing iat
is rejected when last_issued_at is set; missing last_issued_at skips
the check (no baseline).

Gated by VERIFY_TOKEN_ISSUED_AT (default True) for downstreams that
need to disable during a migration window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lnagel
lnagel force-pushed the feat/last_issued_at-field branch from ea5b15b to fe907c3 Compare May 13, 2026 11:15
datetime.UTC was added in Python 3.11; the project supports 3.10
(Django 5.2 still supports 3.10). Pre-existing import in utils.py
went unnoticed because tests didn't load the module. Swap utils.py
to django.utils.timezone.now() for consistency with the rest of the
codebase, and the new test files to stdlib timezone.utc for literal
tz-aware datetimes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lnagel
lnagel merged commit 4b6a40e into namespace-ee:main Jun 12, 2026
12 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