1use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use mas_data_model::{
11 Clock, CompatAccessToken, CompatRefreshToken, CompatRefreshTokenState, CompatSession,
12 UlidExt as _,
13};
14use mas_storage::compat::CompatRefreshTokenRepository;
15use rand::Rng;
16use sqlx::PgConnection;
17use ulid::Ulid;
18use uuid::Uuid;
19
20use crate::{DatabaseError, tracing::ExecuteExt};
21
22pub struct PgCompatRefreshTokenRepository<'c> {
25 conn: &'c mut PgConnection,
26}
27
28impl<'c> PgCompatRefreshTokenRepository<'c> {
29 pub fn new(conn: &'c mut PgConnection) -> Self {
32 Self { conn }
33 }
34}
35
36struct CompatRefreshTokenLookup {
37 compat_refresh_token_id: Uuid,
38 refresh_token: String,
39 created_at: DateTime<Utc>,
40 consumed_at: Option<DateTime<Utc>>,
41 compat_access_token_id: Uuid,
42 compat_session_id: Uuid,
43}
44
45impl From<CompatRefreshTokenLookup> for CompatRefreshToken {
46 fn from(value: CompatRefreshTokenLookup) -> Self {
47 let state = match value.consumed_at {
48 Some(consumed_at) => CompatRefreshTokenState::Consumed { consumed_at },
49 None => CompatRefreshTokenState::Valid,
50 };
51
52 Self {
53 id: value.compat_refresh_token_id.into(),
54 state,
55 session_id: value.compat_session_id.into(),
56 token: value.refresh_token,
57 created_at: value.created_at,
58 access_token_id: value.compat_access_token_id.into(),
59 }
60 }
61}
62
63#[async_trait]
64impl CompatRefreshTokenRepository for PgCompatRefreshTokenRepository<'_> {
65 type Error = DatabaseError;
66
67 #[tracing::instrument(
68 name = "db.compat_refresh_token.lookup",
69 skip_all,
70 fields(
71 db.query.text,
72 compat_refresh_token.id = %id,
73 ),
74 err,
75 )]
76 async fn lookup(&mut self, id: Ulid) -> Result<Option<CompatRefreshToken>, Self::Error> {
77 let res = sqlx::query_as!(
78 CompatRefreshTokenLookup,
79 r#"
80 SELECT compat_refresh_token_id
81 , refresh_token
82 , created_at
83 , consumed_at
84 , compat_session_id
85 , compat_access_token_id
86
87 FROM compat_refresh_tokens
88
89 WHERE compat_refresh_token_id = $1
90 "#,
91 Uuid::from(id),
92 )
93 .traced()
94 .fetch_optional(&mut *self.conn)
95 .await?;
96
97 let Some(res) = res else { return Ok(None) };
98
99 Ok(Some(res.into()))
100 }
101
102 #[tracing::instrument(
103 name = "db.compat_refresh_token.find_by_token",
104 skip_all,
105 fields(
106 db.query.text,
107 ),
108 err,
109 )]
110 async fn find_by_token(
111 &mut self,
112 refresh_token: &str,
113 ) -> Result<Option<CompatRefreshToken>, Self::Error> {
114 let res = sqlx::query_as!(
115 CompatRefreshTokenLookup,
116 r#"
117 SELECT compat_refresh_token_id
118 , refresh_token
119 , created_at
120 , consumed_at
121 , compat_session_id
122 , compat_access_token_id
123
124 FROM compat_refresh_tokens
125
126 WHERE refresh_token = $1
127 "#,
128 refresh_token,
129 )
130 .traced()
131 .fetch_optional(&mut *self.conn)
132 .await?;
133
134 let Some(res) = res else { return Ok(None) };
135
136 Ok(Some(res.into()))
137 }
138
139 #[tracing::instrument(
140 name = "db.compat_refresh_token.add",
141 skip_all,
142 fields(
143 db.query.text,
144 compat_refresh_token.id,
145 %compat_session.id,
146 user.id = %compat_session.user_id,
147 ),
148 err,
149 )]
150 async fn add(
151 &mut self,
152 rng: &mut (dyn Rng + Send),
153 clock: &dyn Clock,
154 compat_session: &CompatSession,
155 compat_access_token: &CompatAccessToken,
156 token: String,
157 ) -> Result<CompatRefreshToken, Self::Error> {
158 let created_at = clock.now();
159 let id = Ulid::from_datetime_with_rng(created_at, rng);
160 tracing::Span::current().record("compat_refresh_token.id", tracing::field::display(id));
161
162 sqlx::query!(
163 r#"
164 INSERT INTO compat_refresh_tokens
165 (compat_refresh_token_id, compat_session_id,
166 compat_access_token_id, refresh_token, created_at)
167 VALUES ($1, $2, $3, $4, $5)
168 "#,
169 Uuid::from(id),
170 Uuid::from(compat_session.id),
171 Uuid::from(compat_access_token.id),
172 token,
173 created_at,
174 )
175 .traced()
176 .execute(&mut *self.conn)
177 .await?;
178
179 Ok(CompatRefreshToken {
180 id,
181 state: CompatRefreshTokenState::default(),
182 session_id: compat_session.id,
183 access_token_id: compat_access_token.id,
184 token,
185 created_at,
186 })
187 }
188
189 #[tracing::instrument(
190 name = "db.compat_refresh_token.consume_and_replace",
191 skip_all,
192 fields(
193 db.query.text,
194 %compat_refresh_token.id,
195 %successor_compat_refresh_token.id,
196 compat_session.id = %compat_refresh_token.session_id,
197 ),
198 err,
199 )]
200 async fn consume_and_replace(
201 &mut self,
202 clock: &dyn Clock,
203 compat_refresh_token: CompatRefreshToken,
204 successor_compat_refresh_token: &CompatRefreshToken,
205 ) -> Result<CompatRefreshToken, Self::Error> {
206 if compat_refresh_token.session_id != successor_compat_refresh_token.session_id {
207 return Err(DatabaseError::invalid_operation());
208 }
209
210 let consumed_at = clock.now();
211 let res = sqlx::query!(
212 r#"
213 UPDATE compat_refresh_tokens
214 SET consumed_at = $2
215 WHERE compat_session_id = $1
216 AND consumed_at IS NULL
217 AND compat_refresh_token_id <> $3
218 "#,
219 Uuid::from(compat_refresh_token.session_id),
220 consumed_at,
221 Uuid::from(successor_compat_refresh_token.id),
222 )
223 .traced()
224 .execute(&mut *self.conn)
225 .await?;
226
227 if res.rows_affected() == 0 {
231 return Err(DatabaseError::RowsAffected {
232 expected: 1,
233 actual: 0,
234 });
235 }
236
237 let compat_refresh_token = compat_refresh_token
238 .consume(consumed_at)
239 .map_err(DatabaseError::to_invalid_operation)?;
240
241 Ok(compat_refresh_token)
242 }
243}