1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
//! The storage trait refresh tokens are persisted through.
use super::types::{RefreshToken, RefreshTokenId, TokenFamilyId};
use crate::authn::ids::{DeviceId, TenantId, UserId};
/// What [`RefreshTokenStore::rotate_token`] found when it revoked the parent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationOutcome {
/// The parent was live, is now revoked, and the child is stored.
Rotated,
/// The parent was already revoked when the rotation ran: a parallel
/// refresh won, nothing was stored, and the caller treats this
/// presentation as a reuse.
ParentAlreadyRevoked,
}
/// Async storage backend for refresh tokens.
///
/// All methods accept `&self`; implementations use interior mutability.
pub trait RefreshTokenStore: Send + Sync {
/// The error type returned by storage operations.
type Error: std::error::Error + Send + Sync + 'static;
/// Persist a new refresh token record.
fn store_token(
&self,
token: &RefreshToken,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
/// Look up a token by its hash. Returns `None` if not found.
fn find_token(
&self,
token_hash: &str,
) -> impl std::future::Future<Output = Result<Option<RefreshToken>, Self::Error>> + Send;
/// Mark a token as revoked by its ID.
fn revoke_token(
&self,
token_id: &RefreshTokenId,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
/// Revoke all tokens for a user (e.g. global logout, credential rotation).
fn revoke_user_tokens(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
/// Return all non-revoked tokens for a user, ordered by `issued_at` ascending.
fn active_tokens(
&self,
user_id: &UserId,
) -> impl std::future::Future<Output = Result<Vec<RefreshToken>, Self::Error>> + Send;
/// Revoke all tokens in a family (same `family_id`).
///
/// Called when a rotated-out (already revoked) token is reused. This is
/// a compromise signal indicating the original token was stolen. All
/// tokens descending from the original issuance must be revoked
/// immediately so that neither the attacker nor the legitimate user can
/// continue refreshing.
///
/// # Contract
///
/// Implementations MUST perform the revocation as a **single atomic
/// statement** scoped to `family_id`, e.g.:
///
/// ```sql
/// UPDATE refresh_tokens
/// SET revoked_at = NOW()
/// WHERE user_id = $1
/// AND family_id = $2
/// AND revoked_at IS NULL
/// ```
///
/// Two failure modes the contract is meant to prevent:
///
/// 1. **Non-atomic load+update**: a backend that lists active tokens
/// and then revokes them one-by-one races with a parallel
/// `refresh_session` call from the attacker; the attacker may
/// succeed in rotating an unrevoked sibling between the list and
/// the per-row revoke, escaping the family invalidation.
/// 2. **Family scope ignored**: delegating to `revoke_user_tokens`
/// would over-revoke (safe, but boots the legitimate user out of
/// every other concurrent device). With per-family scoping, only
/// the compromised device chain is killed.
fn revoke_family(
&self,
user_id: &UserId,
family_id: &TokenFamilyId,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
/// Atomically issue a new refresh token while evicting the
/// per-user cap-overflow set in a **single transaction**.
///
/// The naive sequence (`revoke_token(evict[i])` ... then
/// `store_token(new)`) is not atomic. If a `store_token` failure
/// arrives after the evictions, the user has just lost N legitimate
/// active tokens for nothing; if it arrives before, an in-flight
/// concurrent issue could push the active set above the cap.
///
/// # Requirement
///
/// This method is **required**: implementations MUST wrap the
/// per-id revocations and the new-token insert in a single transaction
/// (or equivalent atomic primitive) so partial-failure semantics
/// match; either all evictions land and the new token is stored, or
/// nothing changes.
fn issue_with_eviction(
&self,
evict_ids: &[RefreshTokenId],
new_token: &RefreshToken,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
/// Atomically rotate a refresh token: revoke `parent_id` and insert
/// `new_token` in a **single transaction**.
///
/// # Why this exists
///
/// The naive sequence (`revoke_token(parent)` followed by
/// `store_token(new)`) is not atomic. If the second call fails (network
/// blip, DB write error, process crash between the two), the parent is
/// already revoked and no replacement was issued. The user is silently
/// logged out across every device using that token family, with no
/// recovery path short of re-authentication.
///
/// # Contract
///
/// Implementations MUST wrap the parent-revoke and the new-token
/// insert in a single transaction (or equivalent atomic primitive),
/// e.g.:
///
/// 1. A single SQL transaction wrapping `UPDATE … SET revoked_at` +
/// `INSERT INTO refresh_tokens`.
/// 2. A Lua/Redis MULTI/EXEC block on Valkey-class stores.
/// 3. Any equivalent atomic primitive provided by the backend.
///
/// A naive serial revoke + store can leave a user silently logged out
/// across every device in the family if the second call fails
/// mid-flight.
///
/// # The revoke is conditional
///
/// The parent is revoked only if it is NOT yet revoked (`UPDATE … SET
/// revoked = 1 WHERE id = ? AND revoked = 0`, or the backend's
/// compare-and-set). When that changes no row, another refresh of the
/// same token won the race: the implementation stores nothing and answers
/// [`RotationOutcome::ParentAlreadyRevoked`], which the caller treats as
/// a reuse of a rotated-out token. Without this, two parallel refreshes
/// with one token both succeeded and left two live children, and the
/// reuse detection never fired.
fn rotate_token(
&self,
parent_id: &RefreshTokenId,
new_token: &RefreshToken,
) -> impl std::future::Future<Output = Result<RotationOutcome, Self::Error>> + Send;
/// Called when a rotated-out token is reused (a token compromise signal).
///
/// Override this to alert operators (e.g. send to a SIEM, page on-call,
/// or log at a higher severity). The default implementation logs a warning.
///
/// This is called *after* `revoke_family` has already revoked the
/// compromised token family.
///
/// # Device cascade
///
/// `compromised_devices` carries every `(TenantId, DeviceId)` pair
/// linked to refresh tokens in the compromised family: the
/// already-revoked token under reuse plus any sibling members that
/// were still active when reuse was detected. Production overrides
/// SHOULD pass these to
/// [`cascade_revoke_devices`](crate::device::cascade_revoke_devices)
/// so every device that participated in the family transitions to
/// [`DeviceTrustLevel::Revoked`](crate::device::DeviceTrustLevel::Revoked).
/// The default implementation logs the device list alongside the
/// family id; it does NOT perform the cascade itself because the
/// trait has no access to a [`DeviceStore`](crate::device::DeviceStore).
/// The cascade primitive lives outside the refresh path so a single
/// application can wire its own DeviceStore once and reuse it from
/// any number of compromise hooks.
fn on_token_compromise(
&self,
user_id: &UserId,
family_id: &TokenFamilyId,
compromised_devices: &[(TenantId, DeviceId)],
) -> impl std::future::Future<Output = ()> + Send {
tracing::warn!(
%user_id,
%family_id,
device_count = compromised_devices.len(),
"refresh token compromise detected (rotated-out token was reused); \
override on_token_compromise + call cascade_revoke_devices to revoke linked devices"
);
async {}
}
}