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, UserError};
13use crate::models::{User, UserRole, UserRow, 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 link_federated(
30        &self,
31        issuer: &str,
32        external_sub: &str,
33        user_id: &UserId,
34    ) -> Result<()> {
35        sqlx::query!(
36            "INSERT INTO federated_identities (issuer, external_sub, user_id) VALUES ($1, $2, $3) \
37             ON CONFLICT (issuer, external_sub) DO UPDATE SET user_id = EXCLUDED.user_id, \
38             last_seen_at = CURRENT_TIMESTAMP",
39            issuer,
40            external_sub,
41            user_id.as_str()
42        )
43        .execute(&*self.write_pool)
44        .await?;
45        Ok(())
46    }
47
48    pub async fn find_or_create_federated(
49        &self,
50        issuer: &str,
51        external_sub: &str,
52        claims: &FederatedIdentityClaims,
53    ) -> Result<User> {
54        let mut conn = self.write_pool.acquire().await?;
55        let mut tx = conn.begin().await?;
56
57        if let Some(existing) = touch_existing_link(&mut tx, issuer, external_sub).await? {
58            tx.commit().await?;
59            return Ok(existing);
60        }
61
62        if let Some(existing) =
63            link_by_verified_email(&mut tx, issuer, external_sub, claims).await?
64        {
65            tx.commit().await?;
66            return Ok(existing);
67        }
68
69        let user = insert_federated_user(&mut tx, issuer, external_sub, claims).await?;
70        tx.commit().await?;
71        Ok(user)
72    }
73}
74
75async fn touch_existing_link(
76    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
77    issuer: &str,
78    external_sub: &str,
79) -> Result<Option<User>> {
80    let Some(existing) = sqlx::query!(
81        "UPDATE federated_identities SET last_seen_at = CURRENT_TIMESTAMP WHERE issuer = $1 \
82         AND external_sub = $2 RETURNING user_id",
83        issuer,
84        external_sub
85    )
86    .fetch_optional(&mut **tx)
87    .await?
88    else {
89        return Ok(None);
90    };
91    let user = sqlx::query_as!(
92        UserRow,
93        r#"
94        SELECT id, name, email, full_name, display_name, status,
95               email_verified, roles, avatar_url, is_bot, is_scanner,
96               created_at, updated_at, account_kind
97        FROM users WHERE id = $1
98        "#,
99        existing.user_id
100    )
101    .fetch_one(&mut **tx)
102    .await
103    .map_err(UserError::from)
104    .and_then(User::try_from)?;
105    Ok(Some(user))
106}
107
108async fn insert_federated_user(
109    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
110    issuer: &str,
111    external_sub: &str,
112    claims: &FederatedIdentityClaims,
113) -> Result<User> {
114    let fields = NewFederatedUser::derive(issuer, external_sub, claims);
115
116    let user = sqlx::query_as!(
117        UserRow,
118        r#"
119        INSERT INTO users (
120            id, name, email, full_name, display_name,
121            status, email_verified, roles, is_bot,
122            created_at, updated_at
123        )
124        VALUES ($1, $2, $3, $4, $5, $6, false, $7::TEXT[], false, $8, $8)
125        RETURNING id, name, email, full_name, display_name, status, email_verified,
126                  roles, avatar_url, is_bot, is_scanner, created_at, updated_at, account_kind
127        "#,
128        fields.id.as_str(),
129        fields.name,
130        fields.email,
131        fields.display_name.as_deref(),
132        fields.display_name.as_deref(),
133        fields.status.as_str(),
134        &fields.roles,
135        fields.now,
136    )
137    .fetch_one(&mut **tx)
138    .await
139    .map_err(UserError::from)
140    .and_then(User::try_from)?;
141
142    sqlx::query!(
143        "INSERT INTO federated_identities (issuer, external_sub, user_id) VALUES ($1, $2, $3)",
144        issuer,
145        external_sub,
146        user.id.as_str()
147    )
148    .execute(&mut **tx)
149    .await?;
150    Ok(user)
151}
152
153async fn link_by_verified_email(
154    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
155    issuer: &str,
156    external_sub: &str,
157    claims: &FederatedIdentityClaims,
158) -> Result<Option<User>> {
159    if !claims.email_verified {
160        return Ok(None);
161    }
162    let Some(addr) = claims.email.as_deref() else {
163        return Ok(None);
164    };
165    let email = normalise_email(addr);
166    let deleted_status = UserStatus::Deleted.as_str();
167    let Some(existing) = sqlx::query_as!(
168        UserRow,
169        r#"
170        SELECT id, name, email, full_name, display_name, status,
171               email_verified, roles, avatar_url, is_bot, is_scanner,
172               created_at, updated_at, account_kind
173        FROM users WHERE email = $1 AND status != $2
174        "#,
175        email,
176        deleted_status
177    )
178    .fetch_optional(&mut **tx)
179    .await?
180    .map(User::try_from)
181    .transpose()?
182    else {
183        return Ok(None);
184    };
185
186    sqlx::query!(
187        "INSERT INTO federated_identities (issuer, external_sub, user_id) VALUES ($1, $2, $3)",
188        issuer,
189        external_sub,
190        existing.id.as_str()
191    )
192    .execute(&mut **tx)
193    .await?;
194    Ok(Some(existing))
195}
196
197struct NewFederatedUser {
198    id: UserId,
199    name: String,
200    email: String,
201    display_name: Option<String>,
202    status: UserStatus,
203    roles: Vec<String>,
204    now: chrono::DateTime<Utc>,
205}
206
207impl NewFederatedUser {
208    fn derive(issuer: &str, external_sub: &str, claims: &FederatedIdentityClaims) -> Self {
209        let name = claims
210            .preferred_username
211            .clone()
212            .or_else(|| claims.name.clone())
213            .unwrap_or_else(|| format!("fed_{}_{}", short_hash(issuer), short_hash(external_sub)));
214        let synthetic_email = || {
215            format!(
216                "{}@{}.federated.local",
217                short_hash(external_sub),
218                short_host(issuer)
219            )
220        };
221        let email = match (claims.email.as_deref(), claims.email_verified) {
222            (Some(addr), true) => normalise_email(addr),
223            (Some(addr), false) => {
224                tracing::warn!(
225                    issuer,
226                    external_sub,
227                    upstream_email = addr,
228                    "upstream IdP did not assert email_verified; using synthetic local email to \
229                     prevent account-claim attacks"
230                );
231                synthetic_email()
232            },
233            (None, _) => synthetic_email(),
234        };
235
236        Self {
237            id: UserId::generate(),
238            name,
239            email,
240            display_name: claims.name.clone(),
241            status: UserStatus::Active,
242            roles: normalised_roles(&claims.roles),
243            now: Utc::now(),
244        }
245    }
246}
247
248fn normalised_roles(claim_roles: &[String]) -> Vec<String> {
249    if claim_roles.is_empty() {
250        vec![UserRole::User.as_str().to_owned()]
251    } else {
252        claim_roles.to_vec()
253    }
254}
255
256fn short_hash(s: &str) -> String {
257    use sha2::{Digest, Sha256};
258    let digest = Sha256::digest(s.as_bytes());
259    hex::encode(&digest[..6])
260}
261
262fn short_host(issuer: &str) -> String {
263    issuer
264        .trim_start_matches("https://")
265        .trim_start_matches("http://")
266        .split('/')
267        .next()
268        .unwrap_or("issuer")
269        .replace(['.', ':'], "-")
270}