Skip to main content

auth/
public.rs

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}