Skip to main content

mas_storage_pg/oauth2/
refresh_token.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use 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
19/// An implementation of [`OAuth2RefreshTokenRepository`] for a PostgreSQL
20/// connection
21pub struct PgOAuth2RefreshTokenRepository<'c> {
22    conn: &'c mut PgConnection,
23}
24
25impl<'c> PgOAuth2RefreshTokenRepository<'c> {
26    /// Create a new [`PgOAuth2RefreshTokenRepository`] from an active
27    /// PostgreSQL connection
28    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        // We only consider a token as consumed if also the next token has its
348        // `consumed_at` set. This makes the query a bit expensive to compute,
349        // but is optimised to two index scans and a nested join using the
350        // `oauth2_refresh_token_not_consumed_idx` and
351        // `oauth2_refresh_token_consumed_at_idx` indexes.
352        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}