Skip to content

Commit 4054cf2

Browse files
committed
Record when access tokens are first used
1 parent 3520c01 commit 4054cf2

8 files changed

+151
-7
lines changed

crates/data-model/src/tokens.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub struct AccessToken {
5555
pub access_token: String,
5656
pub created_at: DateTime<Utc>,
5757
pub expires_at: Option<DateTime<Utc>>,
58+
pub first_used_at: Option<DateTime<Utc>>,
5859
}
5960

6061
impl AccessToken {
@@ -88,6 +89,12 @@ impl AccessToken {
8889
}
8990
}
9091

92+
/// Whether the access token was used at least once
93+
#[must_use]
94+
pub fn is_used(&self) -> bool {
95+
self.first_used_at.is_some()
96+
}
97+
9198
/// Mark the access token as revoked
9299
///
93100
/// # Parameters

crates/handlers/src/oauth2/introspection.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ pub(crate) async fn post(
207207

208208
let reply = match token_type {
209209
TokenType::AccessToken => {
210-
let access_token = repo
210+
let mut access_token = repo
211211
.oauth2_access_token()
212212
.find_by_token(token)
213213
.await?
@@ -227,6 +227,14 @@ pub(crate) async fn post(
227227
return Err(RouteError::InvalidOAuthSession);
228228
}
229229

230+
// If this is the first time we're using this token, mark it as used
231+
if !access_token.is_used() {
232+
access_token = repo
233+
.oauth2_access_token()
234+
.mark_used(&clock, access_token)
235+
.await?;
236+
}
237+
230238
// The session might not have a user on it (for Client Credentials grants for
231239
// example), so we're optionally fetching the user
232240
let (sub, username) = if let Some(user_id) = session.user_id {
@@ -443,6 +451,8 @@ pub(crate) async fn post(
443451
}
444452
};
445453

454+
repo.save().await?;
455+
446456
Ok(Json(reply))
447457
}
448458

@@ -625,6 +635,16 @@ mod tests {
625635
.unwrap()
626636
.unwrap();
627637
assert_eq!(session.last_active_at, Some(state.clock.now()));
638+
639+
// And recorded the access token as used
640+
let access_token_lookup = repo
641+
.oauth2_access_token()
642+
.find_by_token(&access_token)
643+
.await
644+
.unwrap()
645+
.unwrap();
646+
assert!(access_token_lookup.is_used());
647+
assert_eq!(access_token_lookup.first_used_at, Some(state.clock.now()));
628648
repo.cancel().await.unwrap();
629649

630650
// Advance the clock to invalidate the access token

crates/storage-pg/.sqlx/query-7189b6136fd08ac9ae7c51bff06fb2254d1bf9e8a97cd7d32ba789c740e0fbdb.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.
Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Copyright 2024 New Vector Ltd.
2+
--
3+
-- SPDX-License-Identifier: AGPL-3.0-only
4+
-- Please see LICENSE in the repository root for full details.
5+
6+
-- Track when the access token was first used. A NULL value means it was never used.
7+
ALTER TABLE oauth2_access_tokens
8+
ADD COLUMN "first_used_at" TIMESTAMP WITH TIME ZONE;

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ struct OAuth2AccessTokenLookup {
3636
created_at: DateTime<Utc>,
3737
expires_at: Option<DateTime<Utc>>,
3838
revoked_at: Option<DateTime<Utc>>,
39+
first_used_at: Option<DateTime<Utc>>,
3940
}
4041

4142
impl From<OAuth2AccessTokenLookup> for AccessToken {
@@ -52,6 +53,7 @@ impl From<OAuth2AccessTokenLookup> for AccessToken {
5253
access_token: value.access_token,
5354
created_at: value.created_at,
5455
expires_at: value.expires_at,
56+
first_used_at: value.first_used_at,
5557
}
5658
}
5759
}
@@ -70,6 +72,7 @@ impl OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'_> {
7072
, expires_at
7173
, revoked_at
7274
, oauth2_session_id
75+
, first_used_at
7376
7477
FROM oauth2_access_tokens
7578
@@ -106,6 +109,7 @@ impl OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'_> {
106109
, expires_at
107110
, revoked_at
108111
, oauth2_session_id
112+
, first_used_at
109113
110114
FROM oauth2_access_tokens
111115
@@ -170,9 +174,20 @@ impl OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'_> {
170174
session_id: session.id,
171175
created_at,
172176
expires_at,
177+
first_used_at: None,
173178
})
174179
}
175180

181+
#[tracing::instrument(
182+
name = "db.oauth2_access_token.revoked",
183+
skip_all,
184+
fields(
185+
db.query.text,
186+
session.id = %access_token.session_id,
187+
%access_token.id,
188+
),
189+
err,
190+
)]
176191
async fn revoke(
177192
&mut self,
178193
clock: &dyn Clock,
@@ -188,6 +203,7 @@ impl OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'_> {
188203
Uuid::from(access_token.id),
189204
revoked_at,
190205
)
206+
.traced()
191207
.execute(&mut *self.conn)
192208
.await?;
193209

@@ -198,6 +214,49 @@ impl OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'_> {
198214
.map_err(DatabaseError::to_invalid_operation)
199215
}
200216

217+
#[tracing::instrument(
218+
name = "db.oauth2_access_token.mark_used",
219+
skip_all,
220+
fields(
221+
db.query.text,
222+
session.id = %access_token.session_id,
223+
%access_token.id,
224+
),
225+
err,
226+
)]
227+
async fn mark_used(
228+
&mut self,
229+
clock: &dyn Clock,
230+
mut access_token: AccessToken,
231+
) -> Result<AccessToken, Self::Error> {
232+
let now = clock.now();
233+
let res = sqlx::query!(
234+
r#"
235+
UPDATE oauth2_access_tokens
236+
SET first_used_at = $2
237+
WHERE oauth2_access_token_id = $1
238+
"#,
239+
Uuid::from(access_token.id),
240+
now,
241+
)
242+
.execute(&mut *self.conn)
243+
.await?;
244+
245+
DatabaseError::ensure_affected_rows(&res, 1)?;
246+
247+
access_token.first_used_at = Some(now);
248+
249+
Ok(access_token)
250+
}
251+
252+
#[tracing::instrument(
253+
name = "db.oauth2_access_token.cleanup_expired",
254+
skip_all,
255+
fields(
256+
db.query.text,
257+
),
258+
err,
259+
)]
201260
async fn cleanup_expired(&mut self, clock: &dyn Clock) -> Result<usize, Self::Error> {
202261
// Cleanup token which expired more than 15 minutes ago
203262
let threshold = clock.now() - Duration::microseconds(15 * 60 * 1000 * 1000);
@@ -208,6 +267,7 @@ impl OAuth2AccessTokenRepository for PgOAuth2AccessTokenRepository<'_> {
208267
"#,
209268
threshold,
210269
)
270+
.traced()
211271
.execute(&mut *self.conn)
212272
.await?;
213273

crates/storage/src/oauth2/access_token.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,22 @@ pub trait OAuth2AccessTokenRepository: Send + Sync {
9191
access_token: AccessToken,
9292
) -> Result<AccessToken, Self::Error>;
9393

94+
/// Mark the access token as used, to track when it was first used
95+
///
96+
/// # Parameters
97+
///
98+
/// * `clock`: The clock used to generate timestamps
99+
/// * `access_token`: The access token to mark as used
100+
///
101+
/// # Errors
102+
///
103+
/// Returns [`Self::Error`] if the underlying repository fails
104+
async fn mark_used(
105+
&mut self,
106+
clock: &dyn Clock,
107+
access_token: AccessToken,
108+
) -> Result<AccessToken, Self::Error>;
109+
94110
/// Cleanup expired access tokens
95111
///
96112
/// Returns the number of access tokens that were cleaned up
@@ -128,5 +144,11 @@ repository_impl!(OAuth2AccessTokenRepository:
128144
access_token: AccessToken,
129145
) -> Result<AccessToken, Self::Error>;
130146

147+
async fn mark_used(
148+
&mut self,
149+
clock: &dyn Clock,
150+
access_token: AccessToken,
151+
) -> Result<AccessToken, Self::Error>;
152+
131153
async fn cleanup_expired(&mut self, clock: &dyn Clock) -> Result<usize, Self::Error>;
132154
);

0 commit comments

Comments
 (0)