Add last_issued_at field to SessionToken - #30
Merged
lnagel merged 12 commits intoJun 12, 2026
Conversation
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
force-pushed
the
feat/last_issued_at-field
branch
from
May 13, 2026 11:15
ea5b15b to
fe907c3
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
last_issued_atDateTimeFieldtoSessionToken, set totimezone.now()on every successfulPOST /authenticate/.last_issued_at = created_atvia a separate non-atomic migration so the row rewrite is not bundled into theAddFieldtransaction.Test plan
uv run ruff check .anduv run ruff format --check .passuv run pytest tests/— 23 passedmanage.py makemigrations --check --dry-run— no further changeslast_issued_at == created_atfor backfilled rows/authenticate/end-to-end: first call setslast_issued_at == created_at; subsequent calls on the same(user, client_id)advancelast_issued_atwhilecreated_atstays put🤖 Generated with Claude Code