systemprompt_users/repository/
federated_identity.rs1use 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
110async 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}