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};
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 let fields = NewFederatedUser::derive(issuer, external_sub, claims);
64
65 let user = sqlx::query_as!(
66 User,
67 r#"
68 INSERT INTO users (
69 id, name, email, full_name, display_name,
70 status, email_verified, roles, is_bot,
71 created_at, updated_at
72 )
73 VALUES ($1, $2, $3, $4, $5, $6, false, $7::TEXT[], false, $8, $8)
74 RETURNING id, name, email, full_name, display_name, status, email_verified,
75 roles, avatar_url, is_bot, is_scanner, created_at, updated_at
76 "#,
77 fields.id.as_str(),
78 fields.name,
79 fields.email,
80 fields.display_name.as_deref(),
81 fields.display_name.as_deref(),
82 fields.status,
83 &fields.roles,
84 fields.now,
85 )
86 .fetch_one(&mut *tx)
87 .await?;
88
89 sqlx::query!(
90 "INSERT INTO federated_identities (issuer, external_sub, user_id) VALUES ($1, $2, $3)",
91 issuer,
92 external_sub,
93 user.id.as_str()
94 )
95 .execute(&mut *tx)
96 .await?;
97
98 tx.commit().await?;
99 Ok(user)
100 }
101}
102
103struct NewFederatedUser {
104 id: UserId,
105 name: String,
106 email: String,
107 display_name: Option<String>,
108 status: &'static str,
109 roles: Vec<String>,
110 now: chrono::DateTime<Utc>,
111}
112
113impl NewFederatedUser {
114 fn derive(issuer: &str, external_sub: &str, claims: &FederatedIdentityClaims) -> Self {
115 let name = claims
116 .preferred_username
117 .clone()
118 .or_else(|| claims.name.clone())
119 .unwrap_or_else(|| format!("fed_{}_{}", short_hash(issuer), short_hash(external_sub)));
120 let synthetic_email = || {
121 format!(
122 "{}@{}.federated.local",
123 short_hash(external_sub),
124 short_host(issuer)
125 )
126 };
127 let email = match (claims.email.as_deref(), claims.email_verified) {
128 (Some(addr), true) => addr.to_owned(),
129 (Some(addr), false) => {
130 tracing::warn!(
131 issuer,
132 external_sub,
133 upstream_email = addr,
134 "upstream IdP did not assert email_verified; using synthetic local email to \
135 prevent account-claim attacks"
136 );
137 synthetic_email()
138 },
139 (None, _) => synthetic_email(),
140 };
141
142 Self {
143 id: UserId::new(uuid::Uuid::new_v4().to_string()),
144 name,
145 email,
146 display_name: claims.name.clone(),
147 status: UserStatus::Active.as_str(),
148 roles: normalised_roles(&claims.roles),
149 now: Utc::now(),
150 }
151 }
152}
153
154fn normalised_roles(claim_roles: &[String]) -> Vec<String> {
155 if claim_roles.is_empty() {
156 vec![UserRole::User.as_str().to_owned()]
157 } else {
158 claim_roles.to_vec()
159 }
160}
161
162fn short_hash(s: &str) -> String {
163 use sha2::{Digest, Sha256};
164 let digest = Sha256::digest(s.as_bytes());
165 hex::encode(&digest[..6])
166}
167
168fn short_host(issuer: &str) -> String {
169 issuer
170 .trim_start_matches("https://")
171 .trim_start_matches("http://")
172 .split('/')
173 .next()
174 .unwrap_or("issuer")
175 .replace(['.', ':'], "-")
176}