use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info};
use super::auth_user::{AuthUserRecord, AuthUserStore};
use crate::control::security::auth_context::AuthStatus;
use crate::control::security::jwt::JwtClaims;
#[derive(Debug, Clone)]
pub struct JitConfig {
pub enabled: bool,
pub sync_claims: bool,
}
impl Default for JitConfig {
fn default() -> Self {
Self {
enabled: false,
sync_claims: true,
}
}
}
pub fn provision_from_jwt(
store: &AuthUserStore,
claims: &JwtClaims,
provider_name: &str,
config: &JitConfig,
org_store: Option<&crate::control::security::org::store::OrgStore>,
) -> crate::Result<AuthStatus> {
let user_id = if claims.user_id != 0 {
claims.user_id.to_string()
} else {
claims.sub.clone()
};
if user_id.is_empty() {
return Err(crate::Error::BadRequest {
detail: "JWT has no user identifier (sub or user_id claim)".into(),
});
}
if let Some(existing) = store.get(&user_id) {
if !existing.is_active {
return Ok(existing.status); }
store.touch(&user_id)?;
if config.sync_claims {
sync_claims(store, &user_id, claims)?;
}
return Ok(existing.status);
}
if !config.enabled {
return Ok(AuthStatus::Active);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let username = if claims.sub.is_empty() {
format!("jwt_user_{}", claims.user_id)
} else {
claims.sub.clone()
};
let email = claims
.extra
.get("email")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let record = AuthUserRecord {
id: user_id.clone(),
username,
email,
tenant_id: claims.tenant_id,
provider: provider_name.into(),
first_seen: now,
last_seen: now,
is_active: true,
status: AuthStatus::Active,
is_external: true,
synced_claims: extract_sync_claims(claims),
};
store.upsert(record)?;
info!(
user_id = %user_id,
provider = %provider_name,
tenant_id = claims.tenant_id,
"JIT user provisioned"
);
if let Some(org_store) = org_store {
let org_ids: Vec<String> = claims
.extra
.get("org_ids")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
for org_id in &org_ids {
org_store.ensure_org(org_id, claims.tenant_id)?;
let _ = org_store.add_member(org_id, &user_id, "member");
}
if !org_ids.is_empty() {
debug!(user_id = %user_id, orgs = ?org_ids, "JIT org memberships populated");
}
}
Ok(AuthStatus::Active)
}
fn sync_claims(store: &AuthUserStore, user_id: &str, claims: &JwtClaims) -> crate::Result<()> {
let Some(mut user) = store.get(user_id) else {
return Ok(());
};
let new_claims = extract_sync_claims(claims);
if user.synced_claims == new_claims {
return Ok(()); }
debug!(user_id = %user_id, "syncing JWT claims");
if let Some(email) = claims.extra.get("email").and_then(|v| v.as_str()) {
user.email = email.to_string();
}
if let Some(status) = claims.extra.get("status").and_then(|v| v.as_str())
&& let Ok(s) = status.parse::<AuthStatus>()
{
user.status = s;
user.is_active = matches!(
s,
AuthStatus::Active | AuthStatus::Restricted | AuthStatus::ReadOnly
);
}
user.synced_claims = new_claims;
store.upsert(user)?;
Ok(())
}
fn extract_sync_claims(claims: &JwtClaims) -> HashMap<String, String> {
let mut map = HashMap::new();
if !claims.sub.is_empty() {
map.insert("sub".into(), claims.sub.clone());
}
for key in ["email", "org_id", "status", "name"] {
if let Some(val) = claims.extra.get(key).and_then(|v| v.as_str()) {
map.insert(key.into(), val.to_string());
}
}
for key in ["roles", "groups", "permissions", "org_ids"] {
if let Some(arr) = claims.extra.get(key).and_then(|v| v.as_array()) {
let strs: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
if !strs.is_empty() {
map.insert(key.into(), strs.join(","));
}
}
}
if !claims.roles.is_empty() && !map.contains_key("roles") {
map.insert("roles".into(), claims.roles.join(","));
}
map
}
#[cfg(test)]
mod tests {
use super::*;
fn test_claims(sub: &str, user_id: u64) -> JwtClaims {
let mut extra = HashMap::new();
extra.insert("email".into(), serde_json::json!("test@example.com"));
JwtClaims {
sub: sub.into(),
tenant_id: 1,
roles: vec!["readwrite".into()],
exp: 9_999_999_999,
nbf: 0,
iat: 1_700_000_000,
iss: "test-provider".into(),
aud: "nodedb".into(),
user_id,
is_superuser: false,
extra,
}
}
#[test]
fn jit_creates_user_on_first_auth() {
let store = AuthUserStore::new();
let claims = test_claims("alice", 42);
let config = JitConfig {
enabled: true,
sync_claims: true,
};
let status = provision_from_jwt(&store, &claims, "test", &config, None).unwrap();
assert_eq!(status, AuthStatus::Active);
assert!(store.is_active("42"));
let user = store.get("42").unwrap();
assert_eq!(user.username, "alice");
assert_eq!(user.email, "test@example.com");
assert_eq!(user.provider, "test");
}
#[test]
fn jit_disabled_doesnt_create() {
let store = AuthUserStore::new();
let claims = test_claims("bob", 99);
let config = JitConfig {
enabled: false,
sync_claims: true,
};
let status = provision_from_jwt(&store, &claims, "test", &config, None).unwrap();
assert_eq!(status, AuthStatus::Active);
assert!(store.get("99").is_none()); }
#[test]
fn deactivated_user_returns_suspended() {
let store = AuthUserStore::new();
let claims = test_claims("alice", 42);
let config = JitConfig {
enabled: true,
sync_claims: true,
};
provision_from_jwt(&store, &claims, "test", &config, None).unwrap();
store.deactivate("42").unwrap();
let status = provision_from_jwt(&store, &claims, "test", &config, None).unwrap();
assert_eq!(status, AuthStatus::Suspended);
}
#[test]
fn claim_sync_updates_email() {
let store = AuthUserStore::new();
let config = JitConfig {
enabled: true,
sync_claims: true,
};
let claims1 = test_claims("alice", 42);
provision_from_jwt(&store, &claims1, "test", &config, None).unwrap();
assert_eq!(store.get("42").unwrap().email, "test@example.com");
let mut claims2 = test_claims("alice", 42);
claims2
.extra
.insert("email".into(), serde_json::json!("alice@new.com"));
provision_from_jwt(&store, &claims2, "test", &config, None).unwrap();
assert_eq!(store.get("42").unwrap().email, "alice@new.com");
}
}