Skip to content

Commit e0cbf4e

Browse files
fix(auth): revoke access tokens with refresh sessions
1 parent b435db0 commit e0cbf4e

26 files changed

Lines changed: 408 additions & 72 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
## Unreleased
2+
3+
### Security
4+
5+
- **DB session revocation now invalidates access tokens immediately.** Database access-token rows carry the
6+
nullable public refresh-session id that issued them. Revoking one session, revoking other sessions,
7+
detecting refresh-token replay, or expiring a refresh session now deletes every linked access token as
8+
well as the refresh chain. Previously, an already-issued access token remained usable until its access
9+
TTL elapsed. **Breaking schema change:** bundled and custom access-token tables need a nullable indexed
10+
`session_id` column before rollout; see Migration.
11+
112
## 5.2.0 (2026-07-13)
213

314
### Security

docs/api/models.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ columns or relationship wiring from the reference classes.
3131
[Configuration](../configuration/user_and_manager.md#custom-sqlalchemy-user-and-token-models) covers the full
3232
support matrix and migration notes.
3333

34+
## `access_token` session ownership
35+
36+
`AccessTokenMixin.session_id` is a nullable, indexed copy of the public refresh-session id. Access-only
37+
flows leave it `null`; login, TOTP verification, and refresh rotation link newly issued DB access tokens
38+
to the corresponding refresh session. The DB strategy uses that index to invalidate only the access
39+
tokens owned by a revoked, expired, or replay-compromised session.
40+
3441
## `refresh_token` session metadata
3542

3643
The bundled `RefreshTokenMixin` stores refresh tokens as keyed digests only. It also adds the DB-backed

docs/api/strategies.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ queries. `DatabaseTokenStrategy` implements that protocol and can:
3636
- revoke one current-user session by public `session_id`;
3737
- revoke all other current-user sessions, preserving the current session when the current refresh
3838
credential can be identified;
39+
- bind newly issued access tokens to their refresh session and delete those access tokens when that
40+
session is revoked, expires, or is invalidated after refresh-token replay;
3941
- identify a public `session_id` from a raw refresh token by hashing the supplied value and comparing
4042
it with stored digests;
4143
- record consumed refresh-token digests during rotation and revoke the whole refresh-session chain when

docs/configuration/user_and_manager.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,9 @@ backend = AuthenticationBackend(
226226
)
227227
```
228228

229+
Custom access-token classes must expose a nullable mapped `session_id`; `AccessTokenMixin` provides
230+
the indexed column used for immediate session-scoped revocation.
231+
229232
Custom refresh-token classes have a session-management and reuse-detection contract in addition to
230233
the shared token contract. They must expose mapped `session_id`, `last_used_at`, and `client_metadata`
231234
attributes, while consumed digest lookup lives in the separate `refresh_token_consumed_digest` table.

docs/deployment.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ metadata columns:
160160
- `last_used_at` — nullable timestamp updated on successful refresh rotation;
161161
- `client_metadata` — nullable bounded JSON for safe hints such as normalized User-Agent.
162162

163+
The access-token table also requires a nullable indexed `session_id VARCHAR(36)` column. New login,
164+
TOTP verification, and refresh flows copy the refresh session's public id into that column so session
165+
revocation can delete linked access tokens immediately. Existing access-token rows cannot be mapped
166+
reliably to historical refresh sessions: leave them null to let the normal access TTL expire, or delete
167+
them during the migration to force immediate re-authentication.
168+
163169
For bundled models, these fields come from `RefreshTokenMixin`. Existing installations must run an
164170
application-owned migration before rollout: add missing columns, backfill each existing row with a
165171
unique UUID-style `session_id`, leave `last_used_at` null for historical sessions, and only store

docs/http_api.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ mount protected controllers manually, pass `security=` to the relevant controlle
2525
| POST | `{auth}/refresh` | `RefreshTokenRequest` (`refresh_token`) | `enable_refresh=True` | New access token from refresh token / cookie. |
2626
| GET | `{auth}/sessions` | None | `include_session_devices=True` | Authenticated; list the current user's active DB-backed refresh sessions. CookieTransport clients can be marked current from the refresh cookie. |
2727
| POST | `{auth}/sessions` | `RefreshTokenRequest` (`refresh_token`) | `include_session_devices=True` | Authenticated; list active refresh sessions while identifying the current bearer refresh session. |
28-
| DELETE | `{auth}/sessions/{session_id}` | None | `include_session_devices=True` | Authenticated; revoke one of the current user's refresh sessions by public session id. |
29-
| POST | `{auth}/sessions/revoke-others` | Optional `RefreshTokenRequest` (`refresh_token`) for bearer clients | `include_session_devices=True` | Authenticated; revoke the current user's other refresh sessions. |
28+
| DELETE | `{auth}/sessions/{session_id}` | None | `include_session_devices=True` | Authenticated; revoke one of the current user's DB sessions and its linked access tokens by public session id. |
29+
| POST | `{auth}/sessions/revoke-others` | Optional `RefreshTokenRequest` (`refresh_token`) for bearer clients | `include_session_devices=True` | Authenticated; revoke the current user's other DB sessions and their linked access tokens. |
3030
| POST | `{auth}/switch-organization` | `SwitchOrganizationRequest` (`organization_slug`) | `organization_config.enabled=True`, `organization_config.include_switch_organization=True`, and a JWT-capable non-API-key backend | Authenticated; verifies target organization membership and returns an organization-bound JWT through the configured transport. |
3131
| POST | `{auth}/organization-invitations/accept` | `OrganizationInvitationTokenRequest` (`token`) | `organization_config.enabled=True`, `organization_config.include_organization_invitations=True` | Authenticated; validates a single-use invitation token, requires the authenticated user's normalized email to match the invitation, creates membership roles from the invitation, and consumes the invitation. |
3232
| POST | `{auth}/organization-invitations/decline` | `OrganizationInvitationTokenRequest` (`token`) | `organization_config.enabled=True`, `organization_config.include_organization_invitations=True` | Authenticated; validates the token and authenticated email, then revokes the pending invitation without creating membership. |
@@ -76,6 +76,11 @@ The first controller slice supports strategies implementing the refresh-session
7676
including the built-in DB token strategy. JWT and Redis token strategies do not provide a session
7777
dashboard in this slice.
7878

79+
The built-in DB strategy links each newly issued access token to the refresh session that issued it.
80+
Deleting a session, revoking other sessions, detecting refresh-token replay, or expiring a refresh
81+
session therefore invalidates its access tokens immediately rather than waiting for their access TTL.
82+
Custom strategies control their own revocation semantics.
83+
7984
| Status | Error code | Applies to | Meaning |
8085
| ------ | ---------- | ---------- | ------- |
8186
| 400 | `SESSION_MANAGEMENT_UNSUPPORTED` | All session/device routes | The configured strategy does not implement refresh-session management. |

docs/migration.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Migration Guide
22

3+
## DB access-token session ownership
4+
5+
DB session revocation now invalidates access tokens immediately instead of revoking only the refresh
6+
chain. Before deploying the new code, add a nullable indexed `session_id` column to the bundled or
7+
custom access-token table:
8+
9+
```python
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
14+
def upgrade() -> None:
15+
op.add_column("access_token", sa.Column("session_id", sa.String(length=36), nullable=True))
16+
op.create_index("ix_access_token_session_id", "access_token", ["session_id"])
17+
```
18+
19+
Historical access tokens cannot be matched safely to historical refresh sessions. Leaving
20+
`session_id` null preserves them until their configured access TTL expires. For a strict rollout with
21+
immediate revocation semantics, delete existing access-token rows in the migration window and require
22+
users to authenticate again. Custom access-token models supplied through `DatabaseTokenModels` must
23+
expose the nullable mapped `session_id` attribute.
24+
325
## Litestar 2.24: explicit dependency annotations
426

527
litestar-auth now requires Litestar `>=2.24.0`. Litestar 2.24 deprecates *inferred*

litestar_auth/_auth_model_mixins.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,12 @@ class AccessTokenMixin(_TokenModelMixin):
734734

735735
auth_user_back_populates: ClassVar[str] = "access_tokens"
736736

737+
session_id: Mapped[str | None] = mapped_column(
738+
String(length=_SESSION_ID_LENGTH),
739+
index=True,
740+
nullable=True,
741+
)
742+
737743

738744
class RefreshTokenMixin(_TokenModelMixin):
739745
"""Shared mapped attributes for refresh-token models."""

litestar_auth/authentication/backend.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from litestar.exceptions import ClientException, NotAuthorizedException
88
from litestar.response import Response
99

10-
from litestar_auth.authentication.strategy.base import ContextualStrategy, SessionBindable, TokenInvalidationCapable
10+
from litestar_auth.authentication.strategy.base import (
11+
ContextualStrategy,
12+
RefreshSessionAccessTokenStrategy,
13+
SessionBindable,
14+
TokenInvalidationCapable,
15+
)
1116
from litestar_auth.authentication.transport.base import LogoutTokenReadable
1217
from litestar_auth.authentication.transport.cookie import CookieTransport
1318
from litestar_auth.exceptions import TokenError
@@ -44,13 +49,23 @@ def with_session[S](self, session: S) -> AuthenticationBackend[UP, ID]:
4449

4550
return type(self)(name=self.name, transport=self.transport, strategy=bound_strategy)
4651

47-
async def login(self, user: UP) -> Response[Any]:
52+
async def login(self, user: UP, *, session_id: str | None = None) -> Response[Any]:
4853
"""Issue a token through the configured strategy and transport.
4954
5055
Returns:
5156
Response mutated by the configured transport for login.
57+
58+
Raises:
59+
TypeError: If session binding is requested for an unsupported strategy.
5260
"""
53-
token = await self.strategy.write_token(user)
61+
if session_id is None:
62+
token = await self.strategy.write_token(user)
63+
elif isinstance(self.strategy, RefreshSessionAccessTokenStrategy):
64+
session_strategy = cast("RefreshSessionAccessTokenStrategy[UP]", self.strategy)
65+
token = await session_strategy.write_token_for_session(user, session_id)
66+
else:
67+
msg = "The configured authentication strategy cannot bind access tokens to refresh sessions."
68+
raise TypeError(msg)
5469
return self.transport.set_login_token(Response(content=None), token)
5570

5671
async def logout(self, user: UP, token: str) -> Response[Any]:

litestar_auth/authentication/strategy/_db_refresh.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from datetime import UTC, datetime, timedelta
6-
from typing import TYPE_CHECKING, Any, cast
6+
from typing import TYPE_CHECKING, Any, Protocol, cast
77

88
from sqlalchemy import delete, select
99

@@ -25,6 +25,13 @@
2525
from litestar_auth.authentication.strategy.db_models import RefreshTokenConsumedDigest
2626

2727

28+
class _SessionBoundAccessTokenRow(Protocol):
29+
"""Access-token fields needed to resolve its refresh session."""
30+
31+
user_id: object
32+
session_id: str | None
33+
34+
2835
class _DatabaseRefreshSessionMixin[UP: UserProtocol[Any], ID](
2936
_DatabaseRefreshTokenRotationMixin[UP, ID],
3037
):
@@ -36,6 +43,7 @@ class _DatabaseRefreshSessionMixin[UP: UserProtocol[Any], ID](
3643
token_bytes: int
3744
_token_hash_secret: bytes
3845
_refresh_token_repository_type: TokenRepositoryType
46+
_access_token_repository_type: TokenRepositoryType
3947
consumed_refresh_token_digest_model: type[RefreshTokenConsumedDigest]
4048

4149
def _repository(self, repository_type: TokenRepositoryType) -> SQLAlchemyAsyncRepository[Any]:
@@ -101,19 +109,23 @@ async def revoke_refresh_session(self, user: UP, session_id: str) -> bool:
101109
``True`` when a matching active session was deleted, otherwise ``False``.
102110
"""
103111
expired_count = await self._delete_expired_refresh_sessions_for_user(user)
112+
session_ids = select(self.refresh_token_model.session_id).where(
113+
self.refresh_token_model.user_id == user.id,
114+
self.refresh_token_model.session_id == session_id,
115+
)
116+
deleted_access_count = await self._execute_delete(
117+
delete(self.access_token_model).where(self.access_token_model.session_id.in_(session_ids)),
118+
)
104119
deleted_marker_count = await self._delete_refresh_session_consumed_digests(
105-
select(self.refresh_token_model.session_id).where(
106-
self.refresh_token_model.user_id == user.id,
107-
self.refresh_token_model.session_id == session_id,
108-
),
120+
session_ids,
109121
)
110122
deleted_count = await self._execute_delete(
111123
delete(self.refresh_token_model).where(
112124
self.refresh_token_model.user_id == user.id,
113125
self.refresh_token_model.session_id == session_id,
114126
),
115127
)
116-
if expired_count or deleted_marker_count or deleted_count:
128+
if expired_count or deleted_access_count or deleted_marker_count or deleted_count:
117129
await self.session.commit()
118130
return deleted_count > 0
119131

@@ -127,11 +139,15 @@ async def revoke_other_refresh_sessions(self, user: UP, current_session_id: str
127139
conditions = [self.refresh_token_model.user_id == user.id]
128140
if current_session_id is not None:
129141
conditions.append(self.refresh_token_model.session_id != current_session_id)
142+
session_ids = select(self.refresh_token_model.session_id).where(*conditions)
143+
deleted_access_count = await self._execute_delete(
144+
delete(self.access_token_model).where(self.access_token_model.session_id.in_(session_ids)),
145+
)
130146
deleted_marker_count = await self._delete_refresh_session_consumed_digests(
131-
select(self.refresh_token_model.session_id).where(*conditions),
147+
session_ids,
132148
)
133149
deleted_count = await self._execute_delete(delete(self.refresh_token_model).where(*conditions))
134-
if expired_count or deleted_marker_count or deleted_count:
150+
if expired_count or deleted_access_count or deleted_marker_count or deleted_count:
135151
await self.session.commit()
136152
return deleted_count
137153

@@ -157,7 +173,7 @@ async def identify_refresh_session(self, user: UP, refresh_token: str) -> str |
157173
await self.session.commit()
158174
return None
159175
if self._is_token_expired(persisted_token.created_at, self.refresh_max_age):
160-
await self._delete_refresh_token_row(persisted_token)
176+
await self._revoke_refresh_session_chain(persisted_token.session_id)
161177
await self.session.commit()
162178
return None
163179
return persisted_token.session_id
@@ -172,9 +188,30 @@ async def _load_refresh_session_row(self, user: UP, session_id: str) -> _Refresh
172188
)
173189
return cast("_RefreshTokenRow | None", result.scalars().first())
174190

191+
async def _load_totp_stepup_refresh_session_row(self, user: UP, session_id: str) -> _RefreshTokenRow | None:
192+
"""Resolve a refresh session from either its public id or a linked access token.
193+
194+
Returns:
195+
Matching refresh-session row, otherwise ``None``.
196+
"""
197+
row = await self._load_refresh_session_row(user, session_id)
198+
if row is not None:
199+
return row
200+
access_token = cast(
201+
"_SessionBoundAccessTokenRow | None",
202+
await self._resolve_token(
203+
self._repository(self._access_token_repository_type),
204+
session_id,
205+
load=[],
206+
),
207+
)
208+
if access_token is None or access_token.user_id != user.id or access_token.session_id is None:
209+
return None
210+
return await self._load_refresh_session_row(user, access_token.session_id)
211+
175212
async def issue_totp_stepup(self, user: UP, session_id: str, *, ttl_seconds: int) -> None:
176213
"""Store a short-lived TOTP step-up marker on a DB-backed refresh session."""
177-
row = await self._load_refresh_session_row(user, session_id)
214+
row = await self._load_totp_stepup_refresh_session_row(user, session_id)
178215
if row is None:
179216
return
180217
metadata = dict(row.client_metadata or {})
@@ -188,7 +225,7 @@ async def issue_totp_stepup(self, user: UP, session_id: str, *, ttl_seconds: int
188225

189226
async def has_recent_totp_verification(self, user: UP, session_id: str) -> bool:
190227
"""Return whether a DB-backed refresh session has a live TOTP step-up marker."""
191-
row = await self._load_refresh_session_row(user, session_id)
228+
row = await self._load_totp_stepup_refresh_session_row(user, session_id)
192229
if row is None:
193230
return False
194231
metadata = row.client_metadata or {}
@@ -211,11 +248,15 @@ async def _delete_expired_refresh_sessions_for_user(self, user: UP) -> int:
211248
Number of deleted rows.
212249
"""
213250
cutoff = datetime.now(tz=UTC) - self.refresh_max_age
251+
expired_session_ids = select(self.refresh_token_model.session_id).where(
252+
self.refresh_token_model.user_id == user.id,
253+
self.refresh_token_model.created_at <= cutoff,
254+
)
255+
await self._execute_delete(
256+
delete(self.access_token_model).where(self.access_token_model.session_id.in_(expired_session_ids)),
257+
)
214258
await self._delete_refresh_session_consumed_digests(
215-
select(self.refresh_token_model.session_id).where(
216-
self.refresh_token_model.user_id == user.id,
217-
self.refresh_token_model.created_at <= cutoff,
218-
),
259+
expired_session_ids,
219260
)
220261
return await self._execute_delete(
221262
delete(self.refresh_token_model).where(

0 commit comments

Comments
 (0)