use std::path::Path;
use crate::error::JoyError;
use crate::model::project::{is_ai_member, Project};
use crate::store;
use crate::vcs::Vcs;
#[derive(Debug, Clone, PartialEq)]
pub struct Identity {
pub member: String,
pub delegated_by: Option<String>,
pub authenticated: bool,
}
impl Identity {
pub fn log_user(&self) -> String {
match &self.delegated_by {
Some(human) => format!("{} delegated-by:{}", self.member, human),
None => self.member.clone(),
}
}
}
pub fn resolve_identity(root: &Path) -> Result<Identity, JoyError> {
let git_email = crate::vcs::default_vcs().user_email()?;
let project = load_project_optional(root);
let project_id = crate::auth::session::project_id(root).ok();
if let Some(env_value) = std::env::var("JOY_SESSION").ok().filter(|s| !s.is_empty()) {
if let Some((sid, ephemeral_private)) = crate::auth::session::parse_session_env(&env_value)
{
if let Ok(Some(sess)) = crate::auth::session::load_session_by_id(&sid) {
if sess.claims.expires > chrono::Utc::now() && is_ai_member(&sess.claims.member) {
let session_matches_project = project_id
.as_ref()
.map(|pid| sess.claims.project_id == *pid)
.unwrap_or(false);
if session_matches_project {
if let Some(ref project) = project {
if project.members.contains_key(&sess.claims.member)
&& ephemeral_public_matches(&sess, &ephemeral_private)
{
return Ok(Identity {
member: sess.claims.member.clone(),
delegated_by: crate::vcs::default_vcs().user_email().ok(),
authenticated: true,
});
}
}
} else if let Some(ref current_pid) = project_id {
eprintln!(
"{}",
cross_project_session_warning(
&sess.claims.project_id,
&sess.claims.member,
current_pid,
)
);
}
}
}
}
}
if let Some(ref pid) = project_id {
if let Some(session_identity) = session_identity(root, &git_email, pid, &project) {
return Ok(session_identity);
}
}
Ok(Identity {
member: git_email,
delegated_by: None,
authenticated: false,
})
}
fn session_identity(
root: &Path,
member: &str,
project_id: &str,
project: &Option<Project>,
) -> Option<Identity> {
if !check_session(root, member, project) {
return None;
}
let delegated_by = crate::auth::session::load_session(project_id, member)
.ok()
.flatten()
.and_then(|_sess| {
if is_ai_member(member) {
crate::vcs::default_vcs().user_email().ok()
} else {
None
}
});
Some(Identity {
member: member.to_string(),
delegated_by,
authenticated: true,
})
}
pub fn has_ai_members(root: &Path) -> bool {
let project = load_project_optional(root);
match project {
Some(p) => p.members.keys().any(|k| is_ai_member(k)),
None => false,
}
}
fn check_session(root: &Path, member: &str, project: &Option<Project>) -> bool {
let Some(project) = project else {
return false;
};
if !project.members.contains_key(member) {
return false;
};
let Ok(project_id) = crate::auth::session::project_id(root) else {
return false;
};
let Ok(Some(sess)) = crate::auth::session::load_session(&project_id, member) else {
return false;
};
if sess.claims.expires <= chrono::Utc::now() || sess.claims.member != member {
return false;
}
if !is_ai_member(member) {
let m = project.members.get(member).unwrap();
let Some(ref pk_hex) = m.verify_key else {
return false;
};
let Ok(pk) = crate::auth::PublicKey::from_hex(pk_hex) else {
return false;
};
if crate::auth::session::validate_session(&sess, &pk, &project_id).is_err() {
return false;
}
let current_tty = crate::auth::session::current_tty();
if sess.claims.tty != current_tty {
return false;
}
return true;
}
false
}
fn cross_project_session_warning(
session_project: &str,
session_member: &str,
current_project: &str,
) -> String {
format!(
"Warning: JOY_SESSION belongs to project {session_project} \
(member {session_member}), but the current project is {current_project}. \
Ask the human for a delegation in this project: \
joy auth token add {session_member}"
)
}
fn ephemeral_public_matches(
sess: &crate::auth::session::SessionToken,
ephemeral_private: &[u8; 32],
) -> bool {
let Some(ref stored_pk_hex) = sess.claims.session_public_key else {
return false;
};
let kp = crate::auth::IdentityKeypair::from_seed(ephemeral_private);
kp.public_key().to_hex() == *stored_pk_hex
}
fn load_project_optional(root: &Path) -> Option<Project> {
let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
store::read_project(&project_path).ok()
}
#[allow(dead_code)]
fn validate_member(member: &str, project: &Option<Project>) -> Result<(), JoyError> {
let Some(project) = project else {
return Ok(());
};
if project.members.is_empty() {
return Ok(());
}
if !project.members.contains_key(member) {
return Err(JoyError::Other(format!(
"'{}' is not a registered project member. \
Use `joy member add {}` to register.",
member, member
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_log_user_simple() {
let id = Identity {
member: "alice@example.com".into(),
delegated_by: None,
authenticated: false,
};
assert_eq!(id.log_user(), "alice@example.com");
}
#[test]
fn identity_log_user_delegated() {
let id = Identity {
member: "ai:claude@joy".into(),
delegated_by: Some("horst@joydev.com".into()),
authenticated: false,
};
assert_eq!(id.log_user(), "ai:claude@joy delegated-by:horst@joydev.com");
}
#[test]
fn cross_project_warning_names_session_and_current_projects() {
let msg = cross_project_session_warning("JOY", "ai:claude@joy", "JI");
assert!(msg.contains("belongs to project JOY"));
assert!(msg.contains("member ai:claude@joy"));
assert!(msg.contains("current project is JI"));
assert!(msg.contains("joy auth token add ai:claude@joy"));
}
}