Skip to main content

systemprompt_users/repository/
federated_identity.rs

1//! Repository for `federated_identities` — the `{issuer, external_sub} ->
2//! users.id` mapping used by RFC 8693 token-exchange first-touch.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use chrono::Utc;
8use sqlx::Acquire;
9use systemprompt_identifiers::UserId;
10use systemprompt_traits::FederatedIdentityClaims;
11
12use crate::error::Result;
13use crate::models::{User, UserRole, UserStatus, normalise_email};
14use crate::repository::UserRepository;
15
16impl UserRepository {
17    pub async fn find_federated(&self, issuer: &str, external_sub: &str) -> Result<Option<UserId>> {
18        let row = sqlx::query!(
19            "SELECT user_id FROM federated_identities WHERE issuer = $1 AND external_sub = $2",
20            issuer,
21            external_sub
22        )
23        .fetch_optional(&*self.pool)
24        .await?;
25
26        Ok(row.map(|r| UserId::new(r.user_id)))
27    }
28
29    pub async fn find_or_create_federated(
30        &self,
31        issuer: &str,
32        external_sub: &str,
33        claims: &FederatedIdentityClaims,
34    ) -> Result<User> {
35        let mut conn = self.write_pool.acquire().await?;
36        let mut tx = conn.begin().await?;
37
38        if let Some(existing) = sqlx::query!(
39            "UPDATE federated_identities SET last_seen_at = CURRENT_TIMESTAMP WHERE issuer = $1 \
40             AND external_sub = $2 RETURNING user_id",
41            issuer,
42            external_sub
43        )
44        .fetch_optional(&mut *tx)
45        .await?
46        {
47            let user = sqlx::query_as!(
48                User,
49                r#"
50                SELECT id, name, email, full_name, display_name, status,
51                       email_verified, roles, avatar_url, is_bot, is_scanner,
52                       created_at, updated_at
53                FROM users WHERE id = $1
54                "#,
55                existing.user_id
56            )
57            .fetch_one(&mut *tx)
58            .await?;
59            tx.commit().await?;
60            return Ok(user);
61        }
62
63        if let Some(existing) =
64            link_by_verified_email(&mut tx, issuer, external_sub, claims).await?
65        {
66            tx.commit().await?;
67            return Ok(existing);
68        }
69
70        let fields = NewFederatedUser::derive(issuer, external_sub, claims);
71
72        let user = sqlx::query_as!(
73            User,
74            r#"
75            INSERT INTO users (
76                id, name, email, full_name, display_name,
77                status, email_verified, roles, is_bot,
78                created_at, updated_at
79            )
80            VALUES ($1, $2, $3, $4, $5, $6, false, $7::TEXT[], false, $8, $8)
81            RETURNING id, name, email, full_name, display_name, status, email_verified,
82                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
83            "#,
84            fields.id.as_str(),
85            fields.name,
86            fields.email,
87            fields.display_name.as_deref(),
88            fields.display_name.as_deref(),
89            fields.status,
90            &fields.roles,
91            fields.now,
92        )
93        .fetch_one(&mut *tx)
94        .await?;
95
96        sqlx::query!(
97            "INSERT INTO federated_identities (issuer, external_sub, user_id) VALUES ($1, $2, $3)",
98            issuer,
99            external_sub,
100            user.id.as_str()
101        )
102        .execute(&mut *tx)
103        .await?;
104
105        tx.commit().await?;
106        Ok(user)
107    }
108}
109
110// Why: a verified upstream email attaches this sign-in to the existing
111// account instead of minting a duplicate user — one human, one row.
112// Unverified emails never link (account-claim defence in `derive`).
113async fn link_by_verified_email(
114    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
115    issuer: &str,
116    external_sub: &str,
117    claims: &FederatedIdentityClaims,
118) -> Result<Option<User>> {
119    if !claims.email_verified {
120        return Ok(None);
121    }
122    let Some(addr) = claims.email.as_deref() else {
123        return Ok(None);
124    };
125    let email = normalise_email(addr);
126    let deleted_status = UserStatus::Deleted.as_str();
127    let Some(existing) = sqlx::query_as!(
128        User,
129        r#"
130        SELECT id, name, email, full_name, display_name, status,
131               email_verified, roles, avatar_url, is_bot, is_scanner,
132               created_at, updated_at
133        FROM users WHERE email = $1 AND status != $2
134        "#,
135        email,
136        deleted_status
137    )
138    .fetch_optional(&mut **tx)
139    .await?
140    else {
141        return Ok(None);
142    };
143
144    sqlx::query!(
145        "INSERT INTO federated_identities (issuer, external_sub, user_id) VALUES ($1, $2, $3)",
146        issuer,
147        external_sub,
148        existing.id.as_str()
149    )
150    .execute(&mut **tx)
151    .await?;
152    Ok(Some(existing))
153}
154
155struct NewFederatedUser {
156    id: UserId,
157    name: String,
158    email: String,
159    display_name: Option<String>,
160    status: &'static str,
161    roles: Vec<String>,
162    now: chrono::DateTime<Utc>,
163}
164
165impl NewFederatedUser {
166    fn derive(issuer: &str, external_sub: &str, claims: &FederatedIdentityClaims) -> Self {
167        let name = claims
168            .preferred_username
169            .clone()
170            .or_else(|| claims.name.clone())
171            .unwrap_or_else(|| format!("fed_{}_{}", short_hash(issuer), short_hash(external_sub)));
172        let synthetic_email = || {
173            format!(
174                "{}@{}.federated.local",
175                short_hash(external_sub),
176                short_host(issuer)
177            )
178        };
179        let email = match (claims.email.as_deref(), claims.email_verified) {
180            (Some(addr), true) => normalise_email(addr),
181            (Some(addr), false) => {
182                tracing::warn!(
183                    issuer,
184                    external_sub,
185                    upstream_email = addr,
186                    "upstream IdP did not assert email_verified; using synthetic local email to \
187                     prevent account-claim attacks"
188                );
189                synthetic_email()
190            },
191            (None, _) => synthetic_email(),
192        };
193
194        Self {
195            id: UserId::new(uuid::Uuid::new_v4().to_string()),
196            name,
197            email,
198            display_name: claims.name.clone(),
199            status: UserStatus::Active.as_str(),
200            roles: normalised_roles(&claims.roles),
201            now: Utc::now(),
202        }
203    }
204}
205
206fn normalised_roles(claim_roles: &[String]) -> Vec<String> {
207    if claim_roles.is_empty() {
208        vec![UserRole::User.as_str().to_owned()]
209    } else {
210        claim_roles.to_vec()
211    }
212}
213
214fn short_hash(s: &str) -> String {
215    use sha2::{Digest, Sha256};
216    let digest = Sha256::digest(s.as_bytes());
217    hex::encode(&digest[..6])
218}
219
220fn short_host(issuer: &str) -> String {
221    issuer
222        .trim_start_matches("https://")
223        .trim_start_matches("http://")
224        .split('/')
225        .next()
226        .unwrap_or("issuer")
227        .replace(['.', ':'], "-")
228}