Skip to content

Commit 177a0f4

Browse files
committed
Allow revoking refresh tokens
This lets us track 'revoked' tokens separately from 'consumed' tokens.
1 parent 0360427 commit 177a0f4

7 files changed

+141
-28
lines changed

crates/data-model/src/tokens.rs

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -118,25 +118,40 @@ pub enum RefreshTokenState {
118118
consumed_at: DateTime<Utc>,
119119
next_refresh_token_id: Option<Ulid>,
120120
},
121+
Revoked {
122+
revoked_at: DateTime<Utc>,
123+
},
121124
}
122125

123126
impl RefreshTokenState {
124127
/// Consume the refresh token, returning a new state.
125128
///
126129
/// # Errors
127130
///
128-
/// Returns an error if the refresh token is already consumed.
131+
/// Returns an error if the refresh token is revoked.
129132
fn consume(
130133
self,
131134
consumed_at: DateTime<Utc>,
132135
replaced_by: &RefreshToken,
133136
) -> Result<Self, InvalidTransitionError> {
134137
match self {
135-
Self::Valid => Ok(Self::Consumed {
138+
Self::Valid | Self::Consumed { .. } => Ok(Self::Consumed {
136139
consumed_at,
137140
next_refresh_token_id: Some(replaced_by.id),
138141
}),
139-
Self::Consumed { .. } => Err(InvalidTransitionError),
142+
Self::Revoked { .. } => Err(InvalidTransitionError),
143+
}
144+
}
145+
146+
/// Revoke the refresh token, returning a new state.
147+
///
148+
/// # Errors
149+
///
150+
/// Returns an error if the refresh token is already consumed or revoked.
151+
pub fn revoke(self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
152+
match self {
153+
Self::Valid => Ok(Self::Revoked { revoked_at }),
154+
Self::Consumed { .. } | Self::Revoked { .. } => Err(InvalidTransitionError),
140155
}
141156
}
142157

@@ -148,19 +163,11 @@ impl RefreshTokenState {
148163
matches!(self, Self::Valid)
149164
}
150165

151-
/// Returns `true` if the refresh token state is [`Consumed`].
152-
///
153-
/// [`Consumed`]: RefreshTokenState::Consumed
154-
#[must_use]
155-
pub fn is_consumed(&self) -> bool {
156-
matches!(self, Self::Consumed { .. })
157-
}
158-
159166
/// Returns the next refresh token ID, if any.
160167
#[must_use]
161168
pub fn next_refresh_token_id(&self) -> Option<Ulid> {
162169
match self {
163-
Self::Valid => None,
170+
Self::Valid | Self::Revoked { .. } => None,
164171
Self::Consumed {
165172
next_refresh_token_id,
166173
..
@@ -197,7 +204,7 @@ impl RefreshToken {
197204
///
198205
/// # Errors
199206
///
200-
/// Returns an error if the refresh token is already consumed.
207+
/// Returns an error if the refresh token is revoked.
201208
pub fn consume(
202209
mut self,
203210
consumed_at: DateTime<Utc>,
@@ -206,6 +213,16 @@ impl RefreshToken {
206213
self.state = self.state.consume(consumed_at, replaced_by)?;
207214
Ok(self)
208215
}
216+
217+
/// Revokes the refresh token and returns a new revoked token
218+
///
219+
/// # Errors
220+
///
221+
/// Returns an error if the refresh token is already revoked.
222+
pub fn revoke(mut self, revoked_at: DateTime<Utc>) -> Result<Self, InvalidTransitionError> {
223+
self.state = self.state.revoke(revoked_at)?;
224+
Ok(self)
225+
}
209226
}
210227

211228
/// Type of token to generate or validate

crates/storage-pg/.sqlx/query-66693f31eff5673e88ca516ee727a709b06455e08b9fd75cc08f142070f330b3.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/.sqlx/query-5b94c22d44692a16fa5a6edd5dac019c36bf5983182b0871de6e85036d8df466.json renamed to crates/storage-pg/.sqlx/query-6d71188dffc492ddc8f7f21476516d3b08fd5d736ecf36845e6fd4bfc515b2cf.json

Lines changed: 10 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/.sqlx/query-265a981142194216593cd09cd8f6af36c103e030358262e46a4bd5e4006dc630.json renamed to crates/storage-pg/.sqlx/query-a75a6a08c9639053cfc3cffa9d4a009785f358b334f5c586c2e358f0d0b4d856.json

Lines changed: 10 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/src/oauth2/access_token.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ impl OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'_> {
179179
}
180180

181181
#[tracing::instrument(
182-
name = "db.oauth2_access_token.revoked",
182+
name = "db.oauth2_access_token.revoke",
183183
skip_all,
184184
fields(
185185
db.query.text,

crates/storage-pg/src/oauth2/refresh_token.rs

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct OAuth2RefreshTokenLookup {
3434
refresh_token: String,
3535
created_at: DateTime<Utc>,
3636
consumed_at: Option<DateTime<Utc>>,
37+
revoked_at: Option<DateTime<Utc>>,
3738
oauth2_access_token_id: Option<Uuid>,
3839
oauth2_session_id: Uuid,
3940
next_oauth2_refresh_token_id: Option<Uuid>,
@@ -44,17 +45,22 @@ impl TryFrom<OAuth2RefreshTokenLookup> for RefreshToken {
4445

4546
fn try_from(value: OAuth2RefreshTokenLookup) -> Result<Self, Self::Error> {
4647
let id = value.oauth2_refresh_token_id.into();
47-
let state = match (value.consumed_at, value.next_oauth2_refresh_token_id) {
48-
(None, None) => RefreshTokenState::Valid,
49-
(Some(consumed_at), None) => RefreshTokenState::Consumed {
48+
let state = match (
49+
value.revoked_at,
50+
value.consumed_at,
51+
value.next_oauth2_refresh_token_id,
52+
) {
53+
(None, None, None) => RefreshTokenState::Valid,
54+
(Some(revoked_at), None, None) => RefreshTokenState::Revoked { revoked_at },
55+
(None, Some(consumed_at), None) => RefreshTokenState::Consumed {
5056
consumed_at,
5157
next_refresh_token_id: None,
5258
},
53-
(Some(consumed_at), Some(id)) => RefreshTokenState::Consumed {
59+
(None, Some(consumed_at), Some(id)) => RefreshTokenState::Consumed {
5460
consumed_at,
5561
next_refresh_token_id: Some(Ulid::from(id)),
5662
},
57-
(None, Some(_)) => {
63+
_ => {
5864
return Err(DatabaseInconsistencyError::on("oauth2_refresh_tokens")
5965
.column("next_oauth2_refresh_token_id")
6066
.row(id))
@@ -93,6 +99,7 @@ impl OAuth2RefreshTokenRepository for PgOAuth2RefreshTokenRepository<'_> {
9399
, refresh_token
94100
, created_at
95101
, consumed_at
102+
, revoked_at
96103
, oauth2_access_token_id
97104
, oauth2_session_id
98105
, next_oauth2_refresh_token_id
@@ -130,6 +137,7 @@ impl OAuth2RefreshTokenRepository for PgOAuth2RefreshTokenRepository<'_> {
130137
, refresh_token
131138
, created_at
132139
, consumed_at
140+
, revoked_at
133141
, oauth2_access_token_id
134142
, oauth2_session_id
135143
, next_oauth2_refresh_token_id
@@ -237,4 +245,40 @@ impl OAuth2RefreshTokenRepository for PgOAuth2RefreshTokenRepository<'_> {
237245
.consume(consumed_at, replaced_by)
238246
.map_err(DatabaseError::to_invalid_operation)
239247
}
248+
249+
#[tracing::instrument(
250+
name = "db.oauth2_refresh_token.revoke",
251+
skip_all,
252+
fields(
253+
db.query.text,
254+
%refresh_token.id,
255+
session.id = %refresh_token.session_id,
256+
),
257+
err,
258+
)]
259+
async fn revoke(
260+
&mut self,
261+
clock: &dyn Clock,
262+
refresh_token: RefreshToken,
263+
) -> Result<RefreshToken, Self::Error> {
264+
let revoked_at = clock.now();
265+
let res = sqlx::query!(
266+
r#"
267+
UPDATE oauth2_refresh_tokens
268+
SET revoked_at = $2
269+
WHERE oauth2_refresh_token_id = $1
270+
"#,
271+
Uuid::from(refresh_token.id),
272+
revoked_at,
273+
)
274+
.traced()
275+
.execute(&mut *self.conn)
276+
.await?;
277+
278+
DatabaseError::ensure_affected_rows(&res, 1)?;
279+
280+
refresh_token
281+
.revoke(revoked_at)
282+
.map_err(DatabaseError::to_invalid_operation)
283+
}
240284
}

crates/storage/src/oauth2/refresh_token.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,32 @@ pub trait OAuth2RefreshTokenRepository: Send + Sync {
8585
/// # Errors
8686
///
8787
/// Returns [`Self::Error`] if the underlying repository fails, or if the
88-
/// token was already consumed
88+
/// token was already consumed or revoked
8989
async fn consume(
9090
&mut self,
9191
clock: &dyn Clock,
9292
refresh_token: RefreshToken,
9393
replaced_by: &RefreshToken,
9494
) -> Result<RefreshToken, Self::Error>;
95+
96+
/// Revoke a refresh token
97+
///
98+
/// Returns the updated [`RefreshToken`]
99+
///
100+
/// # Parameters
101+
///
102+
/// * `clock`: The clock used to generate timestamps
103+
/// * `refresh_token`: The [`RefreshToken`] to revoke
104+
///
105+
/// # Errors
106+
///
107+
/// Returns [`Self::Error`] if the underlying repository fails, or if the
108+
/// token was already revoked or consumed
109+
async fn revoke(
110+
&mut self,
111+
clock: &dyn Clock,
112+
refresh_token: RefreshToken,
113+
) -> Result<RefreshToken, Self::Error>;
95114
}
96115

97116
repository_impl!(OAuth2RefreshTokenRepository:
@@ -117,4 +136,10 @@ repository_impl!(OAuth2RefreshTokenRepository:
117136
refresh_token: RefreshToken,
118137
replaced_by: &RefreshToken,
119138
) -> Result<RefreshToken, Self::Error>;
139+
140+
async fn revoke(
141+
&mut self,
142+
clock: &dyn Clock,
143+
refresh_token: RefreshToken,
144+
) -> Result<RefreshToken, Self::Error>;
120145
);

0 commit comments

Comments
 (0)