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