Skip to content

Commit eac92d0

Browse files
authored
Populate global user display name from id_token on login (ARB-515) (#380)
1 parent eed7a73 commit eac92d0

File tree

3 files changed

+74
-17
lines changed

3 files changed

+74
-17
lines changed

backend/src/auth.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,28 +170,39 @@ pub async fn validate_access_and_id(
170170
})
171171
}
172172

173-
/// Validate an ID token and return its email if the subject matches `expected_sub`.
173+
pub struct IdTokenInfo {
174+
pub name: Option<String>,
175+
pub email: Option<String>,
176+
}
177+
178+
/// Validate an ID token and return its name and email if the subject matches `expected_sub`.
174179
///
175180
/// # Errors
176181
/// Fails if the token is invalid or the subject does not match.
177-
pub async fn validate_id_token_email_for_sub(
182+
pub async fn validate_id_token_for_sub(
178183
id_token: &str,
179184
expected_sub: &str,
180-
) -> anyhow::Result<Option<String>> {
185+
) -> anyhow::Result<IdTokenInfo> {
181186
#[cfg(feature = "dev-mode")]
182187
if id_token.starts_with("test::") {
183188
let test_client = validate_test_token(id_token)?;
184189
if test_client.id != expected_sub {
185190
anyhow::bail!("sub mismatch");
186191
}
187-
return Ok(test_client.email);
192+
return Ok(IdTokenInfo {
193+
name: test_client.name,
194+
email: test_client.email,
195+
});
188196
}
189197

190198
let id_claims: IdClaims = validate_jwt(id_token).await?;
191199
if id_claims.sub != expected_sub {
192200
anyhow::bail!("sub mismatch");
193201
}
194-
Ok(id_claims.email)
202+
Ok(IdTokenInfo {
203+
name: Some(id_claims.name),
204+
email: id_claims.email,
205+
})
195206
}
196207

197208
/// Test-only function to create a `ValidatedClient` from test credentials.

backend/src/global_db.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,41 @@ impl GlobalDB {
132132
})
133133
}
134134

135+
/// Find a global user by `kinde_id`, creating one with a placeholder
136+
/// display name if none exists. Unlike `ensure_global_user`, this does NOT
137+
/// update the display name or email of an existing user — use it from
138+
/// code paths that don't yet have a trusted name for the user.
139+
///
140+
/// # Errors
141+
/// Returns an error on database failure.
142+
pub async fn find_or_create_global_user(
143+
&self,
144+
kinde_id: &str,
145+
placeholder_name: &str,
146+
email: Option<&str>,
147+
) -> Result<GlobalUser, sqlx::Error> {
148+
if let Some(user) = self.get_global_user_by_kinde_id(kinde_id).await? {
149+
return Ok(user);
150+
}
151+
152+
let id = sqlx::query_scalar::<_, i64>(
153+
r"INSERT INTO global_user (kinde_id, display_name, email) VALUES (?, ?, ?) RETURNING id",
154+
)
155+
.bind(kinde_id)
156+
.bind(placeholder_name)
157+
.bind(email)
158+
.fetch_one(&self.pool)
159+
.await?;
160+
161+
Ok(GlobalUser {
162+
id,
163+
kinde_id: kinde_id.to_string(),
164+
display_name: placeholder_name.to_string(),
165+
is_admin: false,
166+
email: email.map(String::from),
167+
})
168+
}
169+
135170
/// Get a global user by `kinde_id`.
136171
///
137172
/// # Errors

backend/src/main.rs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -145,25 +145,36 @@ async fn list_cohorts(
145145
.filter(|v| !v.is_empty())
146146
.map(str::to_owned);
147147

148-
let resolved_email = if let Some(id_token) = id_token_email {
149-
match backend::auth::validate_id_token_email_for_sub(&id_token, &claims.sub).await {
150-
Ok(email) => email,
148+
let (resolved_email, id_token_name) = if let Some(id_token) = id_token_email {
149+
match backend::auth::validate_id_token_for_sub(&id_token, &claims.sub).await {
150+
Ok(info) => (info.email, info.name),
151151
Err(e) => {
152152
tracing::warn!("Invalid x-id-token for /api/cohorts: {e}");
153-
claims.email.clone()
153+
(claims.email.clone(), None)
154154
}
155155
}
156156
} else {
157-
claims.email.clone()
157+
(claims.email.clone(), None)
158158
};
159159

160-
// Ensure global user exists (creates if needed, same as WS auth flow)
161-
let display_name = claims.sub.clone(); // Fallback; WS auth will update with real name
162-
let global_user = state
163-
.global_db
164-
.ensure_global_user(&claims.sub, &display_name, resolved_email.as_deref())
165-
.await
166-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
160+
// Ensure global user exists. Prefer the display name from the id_token so
161+
// users who log in but haven't been added to any cohort still get their
162+
// real name persisted (previously only the WS auth flow populated the
163+
// display name, so these users stayed stuck with their Kinde sub as a
164+
// placeholder).
165+
let global_user = if let Some(name) = id_token_name.as_deref().filter(|n| !n.is_empty()) {
166+
state
167+
.global_db
168+
.ensure_global_user(&claims.sub, name, resolved_email.as_deref())
169+
.await
170+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
171+
} else {
172+
state
173+
.global_db
174+
.find_or_create_global_user(&claims.sub, &claims.sub, resolved_email.as_deref())
175+
.await
176+
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
177+
};
167178

168179
// Link email-based pre-authorizations if we have an email
169180
if let Some(email) = &resolved_email {

0 commit comments

Comments
 (0)