1use crate::resolver::session_token_hash;
2use crate::session_policy::{AllowSessionPolicy, AuthSessionPolicy, SessionCreateInput};
3use chrono::{DateTime, Utc};
4use platform_core::{AppError, AppResult, DbPool, ErrorCode};
5use sqlx::{Postgres, Transaction};
6use std::fmt::Write as _;
7
8pub use crate::models::{AuthSession, AuthUserId};
9pub use crate::session_policy::SessionCreateOptions;
10
11pub fn new_session_token() -> String {
12 let mut bytes = [0u8; 32];
13 getrandom::fill(&mut bytes).expect("OS randomness should be available");
14
15 let mut token = String::with_capacity("sess_".len() + bytes.len() * 2);
16 token.push_str("sess_");
17 for byte in bytes {
18 let _ = write!(token, "{byte:02x}");
19 }
20 token
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct AuthIdentity {
25 pub id: String,
26 pub user_id: AuthUserId,
27}
28
29pub async fn create_user_identity_in_tx(
30 tx: &mut Transaction<'_, Postgres>,
31 user_id: AuthUserId,
32 identity_id: String,
33 provider: &str,
34 provider_subject: &str,
35 created_at: DateTime<Utc>,
36) -> AppResult<AuthIdentity> {
37 create_user_identity_in_tx_with_anonymous(
38 tx,
39 user_id,
40 identity_id,
41 provider,
42 provider_subject,
43 created_at,
44 false,
45 )
46 .await
47}
48
49pub async fn create_anonymous_user_identity_in_tx(
50 tx: &mut Transaction<'_, Postgres>,
51 user_id: AuthUserId,
52 identity_id: String,
53 provider: &str,
54 provider_subject: &str,
55 created_at: DateTime<Utc>,
56) -> AppResult<AuthIdentity> {
57 create_user_identity_in_tx_with_anonymous(
58 tx,
59 user_id,
60 identity_id,
61 provider,
62 provider_subject,
63 created_at,
64 true,
65 )
66 .await
67}
68
69pub async fn link_identity_to_anonymous_user_in_tx(
70 tx: &mut Transaction<'_, Postgres>,
71 user_id: &AuthUserId,
72 identity_id: String,
73 provider: &str,
74 provider_subject: &str,
75 created_at: DateTime<Utc>,
76) -> AppResult<AuthIdentity> {
77 let anonymous_user_exists = sqlx::query_scalar::<_, bool>(
78 r#"
79 select exists(
80 select 1
81 from auth.users
82 where id = $1
83 and is_anonymous
84 and (disabled_at is null or disabled_until <= now())
85 )
86 "#,
87 )
88 .bind(&user_id.0)
89 .fetch_one(&mut **tx)
90 .await
91 .map_err(map_sql_error)?;
92
93 if !anonymous_user_exists {
94 return Err(AppError::new(
95 ErrorCode::Conflict,
96 "Auth user is not anonymous",
97 ));
98 }
99
100 sqlx::query(
101 r#"
102 insert into auth.identities (id, user_id, provider, provider_subject, created_at, updated_at)
103 values ($1, $2, $3, $4, $5, $5)
104 "#,
105 )
106 .bind(&identity_id)
107 .bind(&user_id.0)
108 .bind(provider)
109 .bind(provider_subject)
110 .bind(created_at)
111 .execute(&mut **tx)
112 .await
113 .map_err(map_sql_error)?;
114
115 sqlx::query(
116 r#"
117 update auth.users
118 set is_anonymous = false
119 where id = $1
120 "#,
121 )
122 .bind(&user_id.0)
123 .execute(&mut **tx)
124 .await
125 .map_err(map_sql_error)?;
126
127 Ok(AuthIdentity {
128 id: identity_id,
129 user_id: user_id.clone(),
130 })
131}
132
133async fn create_user_identity_in_tx_with_anonymous(
134 tx: &mut Transaction<'_, Postgres>,
135 user_id: AuthUserId,
136 identity_id: String,
137 provider: &str,
138 provider_subject: &str,
139 created_at: DateTime<Utc>,
140 is_anonymous: bool,
141) -> AppResult<AuthIdentity> {
142 sqlx::query(
143 r#"
144 insert into auth.users (
145 id,
146 is_anonymous,
147 created_at,
148 disabled_at,
149 disabled_reason,
150 disabled_until
151 )
152 values ($1, $2, $3, null, null, null)
153 "#,
154 )
155 .bind(&user_id.0)
156 .bind(is_anonymous)
157 .bind(created_at)
158 .execute(&mut **tx)
159 .await
160 .map_err(map_sql_error)?;
161
162 sqlx::query(
163 r#"
164 insert into auth.identities (id, user_id, provider, provider_subject, created_at, updated_at)
165 values ($1, $2, $3, $4, $5, $5)
166 "#,
167 )
168 .bind(&identity_id)
169 .bind(&user_id.0)
170 .bind(provider)
171 .bind(provider_subject)
172 .bind(created_at)
173 .execute(&mut **tx)
174 .await
175 .map_err(map_sql_error)?;
176
177 Ok(AuthIdentity {
178 id: identity_id,
179 user_id,
180 })
181}
182
183pub async fn find_active_identity(
184 pool: &DbPool,
185 provider: &str,
186 provider_subject: &str,
187) -> AppResult<Option<AuthIdentity>> {
188 sqlx::query_as::<_, IdentityRow>(
189 r#"
190 select identities.id, identities.user_id
191 from auth.identities identities
192 join auth.users users on users.id = identities.user_id
193 where identities.provider = $1
194 and identities.provider_subject = $2
195 and (users.disabled_at is null or users.disabled_until <= now())
196 limit 1
197 "#,
198 )
199 .bind(provider)
200 .bind(provider_subject)
201 .fetch_optional(pool)
202 .await
203 .map(|row| row.map(identity_from_row))
204 .map_err(map_sql_error)
205}
206
207pub async fn create_session(
208 pool: &DbPool,
209 user_id: &AuthUserId,
210 session_id: String,
211 token: String,
212 created_at: DateTime<Utc>,
213 expires_at: DateTime<Utc>,
214) -> AppResult<AuthSession> {
215 create_session_with_policy(
216 pool,
217 user_id,
218 session_id,
219 token,
220 created_at,
221 expires_at,
222 SessionCreateOptions::default(),
223 &AllowSessionPolicy,
224 )
225 .await
226}
227
228pub async fn create_session_with_policy(
229 pool: &DbPool,
230 user_id: &AuthUserId,
231 session_id: String,
232 token: String,
233 created_at: DateTime<Utc>,
234 expires_at: DateTime<Utc>,
235 options: SessionCreateOptions,
236 policy: &dyn AuthSessionPolicy,
237) -> AppResult<AuthSession> {
238 let mut tx = pool.begin().await.map_err(map_sql_error)?;
239 let session = create_session_in_tx_with_policy(
240 &mut tx, user_id, session_id, token, created_at, expires_at, options, policy,
241 )
242 .await?;
243 tx.commit().await.map_err(map_sql_error)?;
244 Ok(session)
245}
246
247pub async fn create_session_in_tx(
248 tx: &mut Transaction<'_, Postgres>,
249 user_id: &AuthUserId,
250 session_id: String,
251 token: String,
252 created_at: DateTime<Utc>,
253 expires_at: DateTime<Utc>,
254) -> AppResult<AuthSession> {
255 create_session_in_tx_with_policy(
256 tx,
257 user_id,
258 session_id,
259 token,
260 created_at,
261 expires_at,
262 SessionCreateOptions::default(),
263 &AllowSessionPolicy,
264 )
265 .await
266}
267
268pub async fn create_session_in_tx_with_policy(
269 tx: &mut Transaction<'_, Postgres>,
270 user_id: &AuthUserId,
271 session_id: String,
272 token: String,
273 created_at: DateTime<Utc>,
274 expires_at: DateTime<Utc>,
275 options: SessionCreateOptions,
276 policy: &dyn AuthSessionPolicy,
277) -> AppResult<AuthSession> {
278 let active_user_exists = sqlx::query_scalar::<_, bool>(
279 r#"
280 select exists(
281 select 1
282 from auth.users
283 where id = $1
284 and (disabled_at is null or disabled_until <= now())
285 )
286 "#,
287 )
288 .bind(&user_id.0)
289 .fetch_one(&mut **tx)
290 .await
291 .map_err(map_sql_error)?;
292
293 if !active_user_exists {
294 return Err(AppError::new(ErrorCode::Forbidden, "Auth user is disabled"));
295 }
296
297 let decision = policy
298 .before_session_create(&SessionCreateInput {
299 user_id: user_id.clone(),
300 session_id: session_id.clone(),
301 proposed_device_id: options.device_id,
302 created_at,
303 expires_at,
304 client: options.client.clone(),
305 })
306 .await?;
307
308 sqlx::query(
309 r#"
310 insert into auth.sessions (
311 id,
312 user_id,
313 token_hash,
314 device_id,
315 client_ip,
316 user_agent,
317 created_at,
318 expires_at,
319 revoked_at
320 )
321 values ($1, $2, $3, $4, $5, $6, $7, $8, null)
322 "#,
323 )
324 .bind(&session_id)
325 .bind(&user_id.0)
326 .bind(session_token_hash(&token))
327 .bind(decision.device_id.as_deref())
328 .bind(options.client.ip.as_deref())
329 .bind(options.client.user_agent.as_deref())
330 .bind(created_at)
331 .bind(expires_at)
332 .execute(&mut **tx)
333 .await
334 .map_err(map_sql_error)?;
335
336 Ok(AuthSession {
337 id: session_id,
338 user_id: user_id.clone(),
339 token,
340 device_id: decision.device_id,
341 expires_at,
342 })
343}
344
345type IdentityRow = (String, String);
346
347fn identity_from_row(row: IdentityRow) -> AuthIdentity {
348 let (id, user_id) = row;
349 AuthIdentity {
350 id,
351 user_id: AuthUserId(user_id),
352 }
353}
354
355fn map_sql_error(source: sqlx::Error) -> AppError {
356 if let sqlx::Error::Database(database_error) = &source {
357 if database_error.constraint() == Some("identities_provider_subject_key") {
358 return AppError::new(ErrorCode::Conflict, "An auth identity already exists")
359 .with_source(source);
360 }
361 }
362
363 AppError::new(ErrorCode::Internal, "Internal server error").with_source(source)
364}