1use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use mas_data_model::{AccessToken, Clock, RefreshToken, RefreshTokenState, Session, UlidExt as _};
11use mas_storage::oauth2::OAuth2RefreshTokenRepository;
12use rand::Rng;
13use sqlx::PgConnection;
14use ulid::Ulid;
15use uuid::Uuid;
16
17use crate::{DatabaseError, DatabaseInconsistencyError, tracing::ExecuteExt};
18
19pub struct PgOAuth2RefreshTokenRepository<'c> {
22 conn: &'c mut PgConnection,
23}
24
25impl<'c> PgOAuth2RefreshTokenRepository<'c> {
26 pub fn new(conn: &'c mut PgConnection) -> Self {
29 Self { conn }
30 }
31}
32
33struct OAuth2RefreshTokenLookup {
34 oauth2_refresh_token_id: Uuid,
35 refresh_token: String,
36 created_at: DateTime<Utc>,
37 consumed_at: Option<DateTime<Utc>>,
38 revoked_at: Option<DateTime<Utc>>,
39 oauth2_access_token_id: Option<Uuid>,
40 oauth2_session_id: Uuid,
41 next_oauth2_refresh_token_id: Option<Uuid>,
42}
43
44impl TryFrom<OAuth2RefreshTokenLookup> for RefreshToken {
45 type Error = DatabaseInconsistencyError;
46
47 fn try_from(value: OAuth2RefreshTokenLookup) -> Result<Self, Self::Error> {
48 let id = value.oauth2_refresh_token_id.into();
49 let state = match (
50 value.revoked_at,
51 value.consumed_at,
52 value.next_oauth2_refresh_token_id,
53 ) {
54 (None, None, None) => RefreshTokenState::Valid,
55 (Some(revoked_at), None, None) => RefreshTokenState::Revoked { revoked_at },
56 (None, Some(consumed_at), None) => RefreshTokenState::Consumed {
57 consumed_at,
58 next_refresh_token_id: None,
59 },
60 (None, Some(consumed_at), Some(id)) => RefreshTokenState::Consumed {
61 consumed_at,
62 next_refresh_token_id: Some(Ulid::from(id)),
63 },
64 _ => {
65 return Err(DatabaseInconsistencyError::on("oauth2_refresh_tokens")
66 .column("next_oauth2_refresh_token_id")
67 .row(id));
68 }
69 };
70
71 Ok(RefreshToken {
72 id,
73 state,
74 session_id: value.oauth2_session_id.into(),
75 refresh_token: value.refresh_token,
76 created_at: value.created_at,
77 access_token_id: value.oauth2_access_token_id.map(Ulid::from),
78 })
79 }
80}
81
82#[async_trait]
83impl OAuth2RefreshTokenRepository for PgOAuth2RefreshTokenRepository<'_> {
84 type Error = DatabaseError;
85
86 #[tracing::instrument(
87 name = "db.oauth2_refresh_token.lookup",
88 skip_all,
89 fields(
90 db.query.text,
91 refresh_token.id = %id,
92 ),
93 )]
94 async fn lookup(&mut self, id: Ulid) -> Result<Option<RefreshToken>, Self::Error> {
95 let res = sqlx::query_as!(
96 OAuth2RefreshTokenLookup,
97 r#"
98 SELECT oauth2_refresh_token_id
99 , refresh_token
100 , created_at
101 , consumed_at
102 , revoked_at
103 , oauth2_access_token_id
104 , oauth2_session_id
105 , next_oauth2_refresh_token_id
106 FROM oauth2_refresh_tokens
107
108 WHERE oauth2_refresh_token_id = $1
109 "#,
110 Uuid::from(id),
111 )
112 .traced()
113 .fetch_optional(&mut *self.conn)
114 .await?;
115
116 let Some(res) = res else { return Ok(None) };
117
118 Ok(Some(res.try_into()?))
119 }
120
121 #[tracing::instrument(
122 name = "db.oauth2_refresh_token.find_by_token",
123 skip_all,
124 fields(
125 db.query.text,
126 ),
127 )]
128 async fn find_by_token(
129 &mut self,
130 refresh_token: &str,
131 ) -> Result<Option<RefreshToken>, Self::Error> {
132 let res = sqlx::query_as!(
133 OAuth2RefreshTokenLookup,
134 r#"
135 SELECT oauth2_refresh_token_id
136 , refresh_token
137 , created_at
138 , consumed_at
139 , revoked_at
140 , oauth2_access_token_id
141 , oauth2_session_id
142 , next_oauth2_refresh_token_id
143 FROM oauth2_refresh_tokens
144
145 WHERE refresh_token = $1
146 "#,
147 refresh_token,
148 )
149 .traced()
150 .fetch_optional(&mut *self.conn)
151 .await?;
152
153 let Some(res) = res else { return Ok(None) };
154
155 Ok(Some(res.try_into()?))
156 }
157
158 #[tracing::instrument(
159 name = "db.oauth2_refresh_token.add",
160 skip_all,
161 fields(
162 db.query.text,
163 %session.id,
164 client.id = %session.client_id,
165 refresh_token.id,
166 ),
167 )]
168 async fn add(
169 &mut self,
170 rng: &mut (dyn Rng + Send),
171 clock: &dyn Clock,
172 session: &Session,
173 access_token: &AccessToken,
174 refresh_token: String,
175 ) -> Result<RefreshToken, Self::Error> {
176 let created_at = clock.now();
177 let id = Ulid::from_datetime_with_rng(created_at, rng);
178 tracing::Span::current().record("refresh_token.id", tracing::field::display(id));
179
180 sqlx::query!(
181 r#"
182 INSERT INTO oauth2_refresh_tokens
183 (oauth2_refresh_token_id, oauth2_session_id, oauth2_access_token_id,
184 refresh_token, created_at)
185 VALUES
186 ($1, $2, $3, $4, $5)
187 "#,
188 Uuid::from(id),
189 Uuid::from(session.id),
190 Uuid::from(access_token.id),
191 refresh_token,
192 created_at,
193 )
194 .traced()
195 .execute(&mut *self.conn)
196 .await?;
197
198 Ok(RefreshToken {
199 id,
200 state: RefreshTokenState::default(),
201 session_id: session.id,
202 refresh_token,
203 access_token_id: Some(access_token.id),
204 created_at,
205 })
206 }
207
208 #[tracing::instrument(
209 name = "db.oauth2_refresh_token.consume",
210 skip_all,
211 fields(
212 db.query.text,
213 %refresh_token.id,
214 session.id = %refresh_token.session_id,
215 ),
216 )]
217 async fn consume(
218 &mut self,
219 clock: &dyn Clock,
220 refresh_token: RefreshToken,
221 replaced_by: &RefreshToken,
222 ) -> Result<RefreshToken, Self::Error> {
223 let consumed_at = clock.now();
224 let res = sqlx::query!(
225 r#"
226 UPDATE oauth2_refresh_tokens
227 SET consumed_at = $2,
228 next_oauth2_refresh_token_id = $3
229 WHERE oauth2_refresh_token_id = $1
230 "#,
231 Uuid::from(refresh_token.id),
232 consumed_at,
233 Uuid::from(replaced_by.id),
234 )
235 .traced()
236 .execute(&mut *self.conn)
237 .await?;
238
239 DatabaseError::ensure_affected_rows(&res, 1)?;
240
241 refresh_token
242 .consume(consumed_at, replaced_by)
243 .map_err(DatabaseError::to_invalid_operation)
244 }
245
246 #[tracing::instrument(
247 name = "db.oauth2_refresh_token.revoke",
248 skip_all,
249 fields(
250 db.query.text,
251 %refresh_token.id,
252 session.id = %refresh_token.session_id,
253 ),
254 )]
255 async fn revoke(
256 &mut self,
257 clock: &dyn Clock,
258 refresh_token: RefreshToken,
259 ) -> Result<RefreshToken, Self::Error> {
260 let revoked_at = clock.now();
261 let res = sqlx::query!(
262 r#"
263 UPDATE oauth2_refresh_tokens
264 SET revoked_at = $2
265 WHERE oauth2_refresh_token_id = $1
266 "#,
267 Uuid::from(refresh_token.id),
268 revoked_at,
269 )
270 .traced()
271 .execute(&mut *self.conn)
272 .await?;
273
274 DatabaseError::ensure_affected_rows(&res, 1)?;
275
276 refresh_token
277 .revoke(revoked_at)
278 .map_err(DatabaseError::to_invalid_operation)
279 }
280
281 #[tracing::instrument(
282 name = "db.oauth2_refresh_token.cleanup_revoked",
283 skip_all,
284 fields(
285 db.query.text,
286 ),
287 )]
288 async fn cleanup_revoked(
289 &mut self,
290 since: Option<DateTime<Utc>>,
291 until: DateTime<Utc>,
292 limit: usize,
293 ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
294 let res = sqlx::query!(
295 r#"
296 WITH
297 to_delete AS (
298 SELECT oauth2_refresh_token_id
299 FROM oauth2_refresh_tokens
300 WHERE revoked_at IS NOT NULL
301 AND ($1::timestamptz IS NULL OR revoked_at >= $1::timestamptz)
302 AND revoked_at < $2::timestamptz
303 ORDER BY revoked_at ASC
304 LIMIT $3
305 FOR UPDATE
306 ),
307
308 deleted AS (
309 DELETE FROM oauth2_refresh_tokens
310 USING to_delete
311 WHERE oauth2_refresh_tokens.oauth2_refresh_token_id = to_delete.oauth2_refresh_token_id
312 RETURNING oauth2_refresh_tokens.revoked_at
313 )
314
315 SELECT
316 COUNT(*) as "count!",
317 MAX(revoked_at) as last_revoked_at
318 FROM deleted
319 "#,
320 since,
321 until,
322 i64::try_from(limit).unwrap_or(i64::MAX),
323 )
324 .traced()
325 .fetch_one(&mut *self.conn)
326 .await?;
327
328 Ok((
329 res.count.try_into().unwrap_or(usize::MAX),
330 res.last_revoked_at,
331 ))
332 }
333
334 #[tracing::instrument(
335 name = "db.oauth2_refresh_token.cleanup_consumed",
336 skip_all,
337 fields(
338 db.query.text,
339 ),
340 )]
341 async fn cleanup_consumed(
342 &mut self,
343 since: Option<DateTime<Utc>>,
344 until: DateTime<Utc>,
345 limit: usize,
346 ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
347 let res = sqlx::query!(
353 r#"
354 WITH
355 to_delete AS (
356 SELECT rts_to_del.oauth2_refresh_token_id
357 FROM oauth2_refresh_tokens rts_to_del
358 LEFT JOIN oauth2_refresh_tokens next_rts
359 ON rts_to_del.next_oauth2_refresh_token_id = next_rts.oauth2_refresh_token_id
360 WHERE rts_to_del.consumed_at IS NOT NULL
361 AND (rts_to_del.next_oauth2_refresh_token_id IS NULL OR next_rts.consumed_at IS NOT NULL)
362 AND ($1::timestamptz IS NULL OR rts_to_del.consumed_at >= $1::timestamptz)
363 AND rts_to_del.consumed_at < $2::timestamptz
364 ORDER BY rts_to_del.consumed_at ASC
365 LIMIT $3
366 ),
367
368 deleted AS (
369 DELETE FROM oauth2_refresh_tokens
370 USING to_delete
371 WHERE oauth2_refresh_tokens.oauth2_refresh_token_id = to_delete.oauth2_refresh_token_id
372 RETURNING oauth2_refresh_tokens.consumed_at
373 )
374
375 SELECT
376 COUNT(*) as "count!",
377 MAX(consumed_at) as last_consumed_at
378 FROM deleted
379 "#,
380 since,
381 until,
382 i64::try_from(limit).unwrap_or(i64::MAX),
383 )
384 .traced()
385 .fetch_one(&mut *self.conn)
386 .await?;
387
388 Ok((
389 res.count.try_into().unwrap_or(usize::MAX),
390 res.last_consumed_at,
391 ))
392 }
393}