Skip to content

fix: use email as fallback if name not present in oidc login #1399

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 78 additions & 17 deletions src/handlers/http/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use ulid::Ulid;
use url::Url;

use crate::{
handlers::{COOKIE_AGE_DAYS, SESSION_COOKIE_NAME, USER_COOKIE_NAME},
handlers::{COOKIE_AGE_DAYS, SESSION_COOKIE_NAME, USER_COOKIE_NAME, USER_ID_COOKIE_NAME},
oidc::{Claims, DiscoveredClient},
parseable::PARSEABLE,
rbac::{
Expand Down Expand Up @@ -102,11 +102,12 @@ pub async fn login(
},
) if basic.verify_password(&password) => {
let user_cookie = cookie_username(&username);
let user_id_cookie = cookie_userid(&username);
let session_cookie =
exchange_basic_for_cookie(user, SessionKey::BasicAuth { username, password });
Ok(redirect_to_client(
query.redirect.as_str(),
[user_cookie, session_cookie],
[user_cookie, user_id_cookie, session_cookie],
))
}
_ => Err(OIDCError::BadRequest("Bad Request".to_string())),
Expand Down Expand Up @@ -166,7 +167,13 @@ pub async fn reply_login(
let username = user_info
.name
.clone()
.expect("OIDC provider did not return a sub which is currently required.");
.or_else(|| user_info.email.clone())
.or_else(|| user_info.sub.clone())
.expect("OIDC provider did not return a usable identifier (name, email or sub)");
let user_id = user_info
.sub
.clone()
.expect("OIDC provider did not return a usable identifier (sub)");
let user_info: user::UserInfo = user_info.into();
Comment on lines 168 to 177
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid panics on missing claims; derive user_id robustly

Using .expect(...) will crash the handler if an OIDC provider omits fields in userinfo. The ID Token’s sub is required; fall back to it and return an error instead of panicking.

-    let username = user_info
-        .name
-        .clone()
-        .or_else(|| user_info.email.clone())
-        .or_else(|| user_info.sub.clone())
-        .expect("OIDC provider did not return a usable identifier (name, email or sub)");
-    let user_id = user_info
-        .sub
-        .clone()
-        .expect("OIDC provider did not return a usable identifier (sub)");
+    let username = user_info
+        .name
+        .clone()
+        .or_else(|| user_info.email.clone())
+        // fall back to ID Token `sub` (required by spec)
+        .unwrap_or_else(|| claims.sub.clone());
+    let user_id = match user_info.sub.clone().or_else(|| Some(claims.sub.clone())) {
+        Some(id) => id,
+        None => return Err(OIDCError::Unauthorized),
+    };
🤖 Prompt for AI Agents
In src/handlers/http/oidc.rs around lines 168 to 177, the code uses .expect()
which causes a panic if required OIDC claims like name, email, or sub are
missing. Replace these .expect() calls with proper error handling: check if the
fields exist, and if not, return a controlled error response instead of
panicking. Ensure user_id is derived from the required sub claim safely,
returning an error if it is absent.

let group: HashSet<String> = claims
.other
Expand All @@ -185,8 +192,14 @@ pub async fn reply_login(
}
}

let existing_user = Users.get_user(&username);
let final_roles = match existing_user {
let default_role = if let Some(default_role) = DEFAULT_ROLE.lock().unwrap().clone() {
HashSet::from([default_role])
} else {
HashSet::new()
};

let existing_user = find_existing_user(&user_info);
let mut final_roles = match existing_user {
Some(ref user) => {
// For existing users: keep existing roles + add new valid OIDC roles
let mut roles = user.roles.clone();
Expand All @@ -196,20 +209,19 @@ pub async fn reply_login(
None => {
// For new users: use valid OIDC roles, fallback to default if none
if valid_oidc_roles.is_empty() {
if let Some(default_role) = DEFAULT_ROLE.lock().unwrap().clone() {
HashSet::from([default_role])
} else {
HashSet::new()
}
default_role.clone()
} else {
valid_oidc_roles
}
}
};

if final_roles.is_empty() {
// If no roles were found, use the default role
final_roles.clone_from(&default_role);
}
let user = match (existing_user, final_roles) {
(Some(user), roles) => update_user_if_changed(user, roles, user_info).await?,
(None, roles) => put_user(&username, roles, user_info).await?,
(None, roles) => put_user(&user_id, roles, user_info).await?,
};
let id = Ulid::new();
Users.new_session(&user, SessionKey::SessionId(id));
Expand All @@ -221,10 +233,36 @@ pub async fn reply_login(

Ok(redirect_to_client(
&redirect_url,
[cookie_session(id), cookie_username(&username)],
[
cookie_session(id),
cookie_username(&username),
cookie_userid(&user_id),
],
))
}

fn find_existing_user(user_info: &user::UserInfo) -> Option<User> {
if let Some(sub) = &user_info.sub
&& let Some(user) = Users.get_user(sub)
{
return Some(user);
}

if let Some(name) = &user_info.name
&& let Some(user) = Users.get_user(name)
{
return Some(user);
}

if let Some(email) = &user_info.email
&& let Some(user) = Users.get_user(email)
{
return Some(user);
}

None
}

fn exchange_basic_for_cookie(user: &User, key: SessionKey) -> Cookie<'static> {
let id = Ulid::new();
Users.remove_session(&key);
Expand Down Expand Up @@ -294,6 +332,14 @@ pub fn cookie_username(username: &str) -> Cookie<'static> {
.finish()
}

pub fn cookie_userid(user_id: &str) -> Cookie<'static> {
Cookie::build(USER_ID_COOKIE_NAME, user_id.to_string())
.max_age(time::Duration::days(COOKIE_AGE_DAYS as i64))
.same_site(SameSite::Strict)
.path("/")
.finish()
}
Comment on lines +335 to +341
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden cookies: set secure and review http_only based on UI needs

Session/user cookies should be secure in production. Consider http_only(true) for session cookie; for username/user_id, keep readable only if the frontend must access them.

 pub fn cookie_userid(user_id: &str) -> Cookie<'static> {
     Cookie::build(USER_ID_COOKIE_NAME, user_id.to_string())
         .max_age(time::Duration::days(COOKIE_AGE_DAYS as i64))
+        .secure(true)
         .same_site(SameSite::Strict)
         .path("/")
         .finish()
 }

Additionally (outside this hunk), consider:

  • In cookie_session: add .secure(true).http_only(true)
  • In cookie_username: add .secure(true) (keep http_only(false) if UI reads it)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn cookie_userid(user_id: &str) -> Cookie<'static> {
Cookie::build(USER_ID_COOKIE_NAME, user_id.to_string())
.max_age(time::Duration::days(COOKIE_AGE_DAYS as i64))
.same_site(SameSite::Strict)
.path("/")
.finish()
}
pub fn cookie_userid(user_id: &str) -> Cookie<'static> {
Cookie::build(USER_ID_COOKIE_NAME, user_id.to_string())
.max_age(time::Duration::days(COOKIE_AGE_DAYS as i64))
.secure(true)
.same_site(SameSite::Strict)
.path("/")
.finish()
}
🤖 Prompt for AI Agents
In src/handlers/http/oidc.rs around lines 335 to 341, enhance cookie security by
adding .secure(true) to the cookie builder to ensure cookies are only sent over
HTTPS. Also, evaluate whether to add .http_only(true) based on whether the
frontend needs to access the user_id cookie; if the UI requires access, keep
http_only(false). For other cookies like cookie_session, add both .secure(true)
and .http_only(true), and for cookie_username, add .secure(true) while keeping
.http_only(false) if the UI reads it.


pub async fn request_token(
oidc_client: Arc<DiscoveredClient>,
login_query: &Login,
Expand Down Expand Up @@ -341,25 +387,40 @@ pub async fn update_user_if_changed(
group: HashSet<String>,
user_info: user::UserInfo,
) -> Result<User, ObjectStorageError> {
// Store the old username before modifying the user object
let old_username = user.username().to_string();
let User { ty, roles, .. } = &mut user;
let UserType::OAuth(oauth_user) = ty else {
unreachable!()
};

// update user only if roles or userinfo has changed
if roles == &group && oauth_user.user_info == user_info {
// Check if userid needs migration to sub (even if nothing else changed)
let needs_userid_migration = if let Some(ref sub) = user_info.sub {
oauth_user.userid != *sub
} else {
false
};

// update user only if roles, userinfo has changed, or userid needs migration
if roles == &group && oauth_user.user_info == user_info && !needs_userid_migration {
return Ok(user);
}

oauth_user.user_info = user_info;
oauth_user.user_info.clone_from(&user_info);
*roles = group;

// Update userid to use sub if available (migration from name-based to sub-based identification)
if let Some(ref sub) = user_info.sub {
oauth_user.userid.clone_from(sub);
}

let mut metadata = get_metadata().await?;

// Find the user entry using the old username (before migration)
if let Some(entry) = metadata
.users
.iter_mut()
.find(|x| x.username() == user.username())
.find(|x| x.username() == old_username)
{
entry.clone_from(&user);
put_metadata(&metadata).await?;
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub const TELEMETRY_TYPE_KEY: &str = "x-p-telemetry-type";
const COOKIE_AGE_DAYS: usize = 7;
const SESSION_COOKIE_NAME: &str = "session";
const USER_COOKIE_NAME: &str = "username";

const USER_ID_COOKIE_NAME: &str = "user_id";
// constants for log Source values for known sources and formats
const LOG_SOURCE_KINESIS: &str = "kinesis";

Expand Down
4 changes: 3 additions & 1 deletion src/rbac/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,10 @@ impl Users {
#[derive(Debug, Serialize, Clone)]
#[serde(rename = "camelCase")]
pub struct UsersPrism {
// username
// sub
pub id: String,
// username
pub username: String,
// oaith or native
pub method: String,
// email only if method is oauth
Expand Down
10 changes: 9 additions & 1 deletion src/rbac/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ impl User {
pub fn new_oauth(username: String, roles: HashSet<String>, user_info: UserInfo) -> Self {
Self {
ty: UserType::OAuth(OAuth {
userid: user_info.name.clone().unwrap_or(username),
userid: user_info
.sub
.clone()
.or_else(|| user_info.name.clone())
.unwrap_or(username),
user_info,
}),
roles,
Expand Down Expand Up @@ -167,6 +171,9 @@ pub struct OAuth {

#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UserInfo {
#[serde(default)]
/// Subject - Identifier for the End-User at the Issuer.
pub sub: Option<String>,
#[serde(default)]
/// User's full name for display purposes.
pub name: Option<String>,
Expand All @@ -185,6 +192,7 @@ pub struct UserInfo {
impl From<openid::Userinfo> for UserInfo {
fn from(user: openid::Userinfo) -> Self {
UserInfo {
sub: user.sub,
name: user.name,
preferred_username: user.preferred_username,
picture: user.picture,
Expand Down
22 changes: 14 additions & 8 deletions src/rbac/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,19 @@ use super::{
};

pub fn to_prism_user(user: &User) -> UsersPrism {
let (id, method, email, picture) = match &user.ty {
UserType::Native(_) => (user.username(), "native", None, None),
UserType::OAuth(oauth) => (
user.username(),
"oauth",
oauth.user_info.email.clone(),
oauth.user_info.picture.clone(),
),
let (id, username, method, email, picture) = match &user.ty {
UserType::Native(_) => (user.username(), user.username(), "native", None, None),
UserType::OAuth(oauth) => {
let username = user.username();
let display_name = oauth.user_info.name.as_deref().unwrap_or(username);
(
username,
display_name,
"oauth",
oauth.user_info.email.clone(),
oauth.user_info.picture.clone(),
)
}
};
Comment on lines +32 to 45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use email as display fallback when name is missing (consistent with login cookie)

Bring to_prism_user in line with reply_login: prefer name, then email, else id (sub). This avoids showing the opaque sub when name is absent.

-        UserType::OAuth(oauth) => {
-            let username = user.username();
-            let display_name = oauth.user_info.name.as_deref().unwrap_or(username);
+        UserType::OAuth(oauth) => {
+            let username = user.username(); // id (sub)
+            let display_name = oauth
+                .user_info
+                .name
+                .as_deref()
+                .or_else(|| oauth.user_info.email.as_deref())
+                .unwrap_or(username);
             (
                 username,
                 display_name,
                 "oauth",
                 oauth.user_info.email.clone(),
                 oauth.user_info.picture.clone(),
             )
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let (id, username, method, email, picture) = match &user.ty {
UserType::Native(_) => (user.username(), user.username(), "native", None, None),
UserType::OAuth(oauth) => {
let username = user.username();
let display_name = oauth.user_info.name.as_deref().unwrap_or(username);
(
username,
display_name,
"oauth",
oauth.user_info.email.clone(),
oauth.user_info.picture.clone(),
)
}
};
let (id, username, method, email, picture) = match &user.ty {
UserType::Native(_) => (user.username(), user.username(), "native", None, None),
UserType::OAuth(oauth) => {
let username = user.username(); // id (sub)
let display_name = oauth
.user_info
.name
.as_deref()
.or_else(|| oauth.user_info.email.as_deref())
.unwrap_or(username);
(
username,
display_name,
"oauth",
oauth.user_info.email.clone(),
oauth.user_info.picture.clone(),
)
}
};
🤖 Prompt for AI Agents
In src/rbac/utils.rs around lines 32 to 45, update the logic for determining the
display name in the OAuth user match arm to prefer the user's name, then
fallback to their email if the name is missing, and finally fallback to the
username (id) if both are absent. This ensures the display name is consistent
with the login cookie behavior and avoids showing an opaque id when the name is
missing.

let direct_roles: HashMap<String, Vec<DefaultPrivilege>> = Users
.get_role(id)
Expand Down Expand Up @@ -69,6 +74,7 @@ pub fn to_prism_user(user: &User) -> UsersPrism {

UsersPrism {
id: id.into(),
username: username.into(),
method: method.into(),
email: mask_pii_string(email),
picture: mask_pii_url(picture),
Expand Down
Loading