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