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 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}