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};
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        let fields = NewFederatedUser::derive(issuer, external_sub, claims);
64
65        let user = sqlx::query_as!(
66            User,
67            r#"
68            INSERT INTO users (
69                id, name, email, full_name, display_name,
70                status, email_verified, roles, is_bot,
71                created_at, updated_at
72            )
73            VALUES ($1, $2, $3, $4, $5, $6, false, $7::TEXT[], false, $8, $8)
74            RETURNING id, name, email, full_name, display_name, status, email_verified,
75                      roles, avatar_url, is_bot, is_scanner, created_at, updated_at
76            "#,
77            fields.id.as_str(),
78            fields.name,
79            fields.email,
80            fields.display_name.as_deref(),
81            fields.display_name.as_deref(),
82            fields.status,
83            &fields.roles,
84            fields.now,
85        )
86        .fetch_one(&mut *tx)
87        .await?;
88
89        sqlx::query!(
90            "INSERT INTO federated_identities (issuer, external_sub, user_id) VALUES ($1, $2, $3)",
91            issuer,
92            external_sub,
93            user.id.as_str()
94        )
95        .execute(&mut *tx)
96        .await?;
97
98        tx.commit().await?;
99        Ok(user)
100    }
101}
102
103struct NewFederatedUser {
104    id: UserId,
105    name: String,
106    email: String,
107    display_name: Option<String>,
108    status: &'static str,
109    roles: Vec<String>,
110    now: chrono::DateTime<Utc>,
111}
112
113impl NewFederatedUser {
114    fn derive(issuer: &str, external_sub: &str, claims: &FederatedIdentityClaims) -> Self {
115        let name = claims
116            .preferred_username
117            .clone()
118            .or_else(|| claims.name.clone())
119            .unwrap_or_else(|| format!("fed_{}_{}", short_hash(issuer), short_hash(external_sub)));
120        let synthetic_email = || {
121            format!(
122                "{}@{}.federated.local",
123                short_hash(external_sub),
124                short_host(issuer)
125            )
126        };
127        let email = match (claims.email.as_deref(), claims.email_verified) {
128            (Some(addr), true) => addr.to_owned(),
129            (Some(addr), false) => {
130                tracing::warn!(
131                    issuer,
132                    external_sub,
133                    upstream_email = addr,
134                    "upstream IdP did not assert email_verified; using synthetic local email to \
135                     prevent account-claim attacks"
136                );
137                synthetic_email()
138            },
139            (None, _) => synthetic_email(),
140        };
141
142        Self {
143            id: UserId::new(uuid::Uuid::new_v4().to_string()),
144            name,
145            email,
146            display_name: claims.name.clone(),
147            status: UserStatus::Active.as_str(),
148            roles: normalised_roles(&claims.roles),
149            now: Utc::now(),
150        }
151    }
152}
153
154fn normalised_roles(claim_roles: &[String]) -> Vec<String> {
155    if claim_roles.is_empty() {
156        vec![UserRole::User.as_str().to_owned()]
157    } else {
158        claim_roles.to_vec()
159    }
160}
161
162fn short_hash(s: &str) -> String {
163    use sha2::{Digest, Sha256};
164    let digest = Sha256::digest(s.as_bytes());
165    hex::encode(&digest[..6])
166}
167
168fn short_host(issuer: &str) -> String {
169    issuer
170        .trim_start_matches("https://")
171        .trim_start_matches("http://")
172        .split('/')
173        .next()
174        .unwrap_or("issuer")
175        .replace(['.', ':'], "-")
176}