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