mod pooling;
pub use pooling::{
EnrollmentPlan, EnrollmentResult, PoolEnrollment, complete, complete_with_config, plan,
};
use crate::{AuthProfileResolutionError, AuthProfileSource, ResolvedAuthProfile};
use bcode_config::{
AuthCredentialMapping, AuthProfileConfig, BcodeConfig, RuntimeAuthSubscriptions,
};
use bcode_provider_auth_models::{AuthMethodContribution, AuthProviderContribution};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
pub struct EnrollmentDestination {
pub profile: Option<String>,
pub vault: Option<PathBuf>,
pub recipient_key: Option<String>,
}
pub struct PreparedEnrollment {
pub resolved: ResolvedAuthProfile,
pub publish_runtime: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum EnrollmentError {
#[error("Authentication registration or method is invalid")]
InvalidMethod,
#[error("Authentication pool is ambiguous or incompatible; existing metadata preserved")]
PoolConflict,
#[error(
"Declare this context's authentication profile before enrollment; unscoped account creation is not permitted"
)]
ContextProfileRequired,
#[error(
"Existing account uses a different vault; choose a new profile or explicitly migrate the account"
)]
DestinationConflict,
#[error(transparent)]
Profile(#[from] AuthProfileResolutionError),
}
pub fn prepare(
config: &BcodeConfig,
runtime: &RuntimeAuthSubscriptions,
provider: &AuthProviderContribution,
owner_plugin_id: &str,
method_id: &str,
destination: EnrollmentDestination,
) -> Result<PreparedEnrollment, EnrollmentError> {
provider
.validate()
.map_err(|_| EnrollmentError::InvalidMethod)?;
let method = provider
.methods
.iter()
.find(|method| method.method_id() == method_id)
.ok_or(EnrollmentError::InvalidMethod)?;
let EnrollmentDestination {
profile,
vault,
recipient_key,
} = destination;
match crate::resolve_auth_provider_profile(
config,
&provider.provider_id,
owner_plugin_id,
profile.as_deref(),
runtime,
) {
Ok(mut resolved) => {
crate::lifecycle::AuthVaultLifecycle::new(
&resolved,
&provider.provider_id,
owner_plugin_id,
method,
)
.map_err(|_| EnrollmentError::InvalidMethod)?;
if let Some(vault) = vault {
let existing_vault = resolved
.profile
.settings
.get("vault")
.map_or_else(bcode_config::default_auth_vault_path, PathBuf::from);
if vault != existing_vault {
return Err(EnrollmentError::DestinationConflict);
}
resolved
.profile
.settings
.insert("vault".to_owned(), vault.display().to_string());
}
if let Some(recipient) = recipient_key {
resolved
.profile
.settings
.insert("recipient_key".to_owned(), recipient);
}
Ok(PreparedEnrollment {
resolved,
publish_runtime: false,
})
}
Err(AuthProfileResolutionError::MissingProfile { .. }) => {
if config.active_context.is_some() {
return Err(EnrollmentError::ContextProfileRequired);
}
let name = profile.unwrap_or_else(|| provider.provider_id.clone());
validate_enrollment_binding(runtime, &provider.provider_id, owner_plugin_id, &name)?;
let vault = vault.unwrap_or_else(bcode_config::default_auth_vault_path);
let mut settings = BTreeMap::from([
("profile".to_owned(), name.clone()),
("vault".to_owned(), vault.display().to_string()),
]);
if let Some(recipient) = recipient_key {
settings.insert("recipient_key".to_owned(), recipient);
}
let map = match method {
AuthMethodContribution::SecretFields { fields, .. } => fields
.iter()
.map(|field| (field.credential_id.clone(), mapping(&field.storage_key)))
.collect(),
AuthMethodContribution::Interactive { credentials, .. } => credentials
.iter()
.map(|field| (field.credential_id.clone(), mapping(&field.storage_key)))
.collect(),
};
Ok(PreparedEnrollment {
resolved: ResolvedAuthProfile {
profile_name: name,
provider_id: provider.provider_id.clone(),
owner_plugin_id: owner_plugin_id.to_owned(),
profile: AuthProfileConfig {
backend: "sshenv".to_owned(),
provider_id: Some(provider.provider_id.clone()),
owner_plugin_id: Some(owner_plugin_id.to_owned()),
scheme: Some(method_id.to_owned()),
map,
settings,
},
source: AuthProfileSource::Runtime,
},
publish_runtime: true,
})
}
Err(error) => Err(error.into()),
}
}
fn validate_enrollment_binding(
runtime: &RuntimeAuthSubscriptions,
provider_id: &str,
owner: &str,
name: &str,
) -> Result<(), EnrollmentError> {
if let Some(binding) = runtime.bindings.get(provider_id)
&& binding.owner_plugin_id != owner
{
return Err(AuthProfileResolutionError::OwnerMismatch {
profile: name.to_owned(),
expected: owner.to_owned(),
actual: binding.owner_plugin_id.clone(),
}
.into());
}
Ok(())
}
pub fn plan_context_account(
path: PathBuf,
context: &str,
local: &str,
provider: &AuthProviderContribution,
owner: &str,
method: &str,
vault: PathBuf,
) -> Result<bcode_config::edit::ConfigEdit, String> {
let prepared = prepare(
&BcodeConfig::default(),
&RuntimeAuthSubscriptions::default(),
provider,
owner,
method,
EnrollmentDestination {
profile: Some(local.to_owned()),
vault: Some(vault),
recipient_key: None,
},
)
.map_err(|error| error.to_string())?;
let mut profile = prepared.resolved.profile;
profile.settings.remove("profile");
bcode_config::edit::plan_context_auth_profile(path, context, local, &profile)
.map_err(|error| error.to_string())
}
#[must_use]
pub fn new_profile_name(
config: &BcodeConfig,
runtime: &RuntimeAuthSubscriptions,
provider_id: &str,
) -> String {
if let Some(context) = config.active_context.as_deref()
&& let Some(definition) = config
.contexts
.as_ref()
.and_then(|contexts| contexts.entries.get(context))
{
let local_config = BcodeConfig {
auth: definition.auth.clone(),
..BcodeConfig::default()
};
return new_profile_name(
&local_config,
&RuntimeAuthSubscriptions::default(),
provider_id,
);
}
let mut occupied: BTreeSet<&str> = config
.auth
.profiles
.keys()
.map(String::as_str)
.chain(runtime.profiles.keys().map(String::as_str))
.collect();
occupied.extend(
config
.auth
.bindings
.values()
.filter_map(|binding| binding.profile.as_deref()),
);
occupied.extend(
runtime
.bindings
.values()
.map(|binding| binding.profile.as_str()),
);
occupied.extend(
config
.auth
.pools
.values()
.flat_map(|pool| pool.profiles.iter().map(String::as_str)),
);
occupied.extend(runtime.pools.values().flat_map(|pool| {
pool.profiles
.iter()
.map(|profile| profile.auth_profile.as_str())
}));
if !occupied.contains(provider_id) {
return provider_id.to_owned();
}
for index in 2_u64.. {
let candidate = format!("{provider_id}-{index}");
if !occupied.contains(candidate.as_str()) {
return candidate;
}
}
unreachable!("profile name space exhausted")
}
fn mapping(key: &str) -> AuthCredentialMapping {
AuthCredentialMapping {
env: None,
key: Some(key.to_owned()),
}
}
pub fn publish(resolved: &ResolvedAuthProfile) -> Result<(), bcode_config::ConfigError> {
bcode_config::register_runtime_auth_profile(
&resolved.profile_name,
bcode_config::RuntimeAuthProfile {
provider_id: resolved.provider_id.clone(),
owner_plugin_id: resolved.owner_plugin_id.clone(),
backend: resolved.profile.backend.clone(),
scheme: resolved.profile.scheme.clone().unwrap_or_default(),
storage_profile: resolved
.profile
.settings
.get("profile")
.cloned()
.unwrap_or_else(|| resolved.profile_name.clone()),
vault: resolved
.profile
.settings
.get("vault")
.map_or_else(bcode_config::default_auth_vault_path, PathBuf::from),
map: resolved.profile.map.clone(),
device_seal: resolved.profile.settings.get("device_seal").cloned(),
},
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_enrollment_declaration_resolves_local_name_without_global_binding() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("bcode.toml");
std::fs::write(
&path,
"[contexts]\nactive = 'alpha'\n[contexts.entries.alpha]\n[contexts.entries.beta]\n",
)
.unwrap();
let provider = AuthProviderContribution {
schema_version: bcode_provider_auth_models::AUTH_PROVIDER_CONTRIBUTION_SCHEMA_VERSION,
provider_id: "example".to_owned(),
display_name: "Example".to_owned(),
methods: vec![AuthMethodContribution::Interactive {
method_id: "browser".to_owned(),
display_name: "Browser".to_owned(),
operation: "auth.browser".to_owned(),
credentials: Vec::new(),
supports_revocation: false,
}],
};
let vault = temp.path().join("vault");
let edit = plan_context_account(
path.clone(),
"alpha",
"example",
&provider,
"example.plugin",
"browser",
vault.clone(),
)
.unwrap();
assert!(!vault.exists());
edit.apply().unwrap();
let config = bcode_config::load_config_from_paths(std::slice::from_ref(&path)).unwrap();
assert_eq!(
new_profile_name(&config, &RuntimeAuthSubscriptions::default(), "example"),
"example-2"
);
let prepared = prepare(
&config,
&RuntimeAuthSubscriptions::default(),
&provider,
"example.plugin",
"browser",
EnrollmentDestination {
profile: Some("example".to_owned()),
vault: Some(vault.clone()),
recipient_key: None,
},
)
.unwrap();
let identity = bcode_config::contexts::qualify("alpha", "example").unwrap();
assert_eq!(prepared.resolved.profile_name, identity);
assert_eq!(prepared.resolved.profile.settings["profile"], identity);
assert!(!prepared.publish_runtime);
assert!(!vault.exists());
let beta = bcode_config::load_config_from_paths_with_overrides(
&[path],
&bcode_config::ConfigLoadOverrides::default()
.with_cli_config_toml(Some("[contexts]\nactive = 'beta'".to_owned())),
)
.unwrap();
assert_eq!(
new_profile_name(&beta, &RuntimeAuthSubscriptions::default(), "example"),
"example"
);
assert!(
prepare(
&beta,
&RuntimeAuthSubscriptions::default(),
&provider,
"example.plugin",
"browser",
EnrollmentDestination {
profile: Some("example".to_owned()),
..Default::default()
}
)
.is_err()
);
}
#[test]
fn new_account_names_do_not_reuse_pool_members_or_dangling_bindings() {
let mut config = BcodeConfig::default();
let mut runtime = RuntimeAuthSubscriptions::default();
assert_eq!(new_profile_name(&config, &runtime, "custom"), "custom");
config.auth.bindings.insert(
"provider".to_owned(),
bcode_config::AuthBindingConfig {
profile: Some("custom".to_owned()),
},
);
runtime.bindings.insert(
"provider".to_owned(),
bcode_config::RuntimeAuthBinding {
profile: "custom-2".to_owned(),
owner_plugin_id: "example.plugin".to_owned(),
},
);
config.auth.pools.insert(
"declared".to_owned(),
bcode_config::AuthPoolConfig {
profiles: vec!["custom-3".to_owned()],
..Default::default()
},
);
runtime.pools.insert(
"runtime".to_owned(),
bcode_config::RuntimeAuthSubscriptionPool {
profiles: vec![bcode_config::RuntimeAuthSubscriptionProfile {
auth_profile: "custom-4".to_owned(),
..Default::default()
}],
..Default::default()
},
);
assert_eq!(new_profile_name(&config, &runtime, "custom"), "custom-5");
assert_eq!(runtime.profiles.len(), 0);
assert!(config.auth.profiles.is_empty());
}
#[test]
fn existing_account_method_is_checked_before_interactive_effects() {
let provider = AuthProviderContribution {
schema_version: bcode_provider_auth_models::AUTH_PROVIDER_CONTRIBUTION_SCHEMA_VERSION,
provider_id: "example".to_owned(),
display_name: "Example".to_owned(),
methods: vec![AuthMethodContribution::Interactive {
method_id: "browser".to_owned(),
display_name: "Browser".to_owned(),
operation: "auth.browser".to_owned(),
credentials: Vec::new(),
supports_revocation: false,
}],
};
let mut config = BcodeConfig::default();
config.auth.profiles.insert(
"account".to_owned(),
AuthProfileConfig {
backend: "sshenv".to_owned(),
provider_id: Some("example".to_owned()),
owner_plugin_id: Some("bcode.example".to_owned()),
scheme: Some("different-method".to_owned()),
map: BTreeMap::new(),
settings: BTreeMap::new(),
},
);
let destination = EnrollmentDestination {
profile: Some("account".to_owned()),
..EnrollmentDestination::default()
};
assert!(matches!(
prepare(
&config,
&RuntimeAuthSubscriptions::default(),
&provider,
"bcode.example",
"browser",
destination.clone(),
),
Err(EnrollmentError::InvalidMethod)
));
config.auth.profiles.get_mut("account").unwrap().scheme = Some("browser".to_owned());
let prepared = prepare(
&config,
&RuntimeAuthSubscriptions::default(),
&provider,
"bcode.example",
"browser",
destination,
)
.expect("matching owned method");
assert!(!prepared.publish_runtime);
let conflict = prepare(
&config,
&RuntimeAuthSubscriptions::default(),
&provider,
"bcode.example",
"browser",
EnrollmentDestination {
profile: Some("account".to_owned()),
vault: Some(PathBuf::from("different-vault")),
recipient_key: None,
},
);
assert!(matches!(
conflict,
Err(EnrollmentError::DestinationConflict)
));
let runtime = RuntimeAuthSubscriptions {
bindings: BTreeMap::from([(
"example".to_owned(),
bcode_config::RuntimeAuthBinding {
profile: "foreign-account".to_owned(),
owner_plugin_id: "foreign.plugin".to_owned(),
},
)]),
..Default::default()
};
assert!(
prepare(
&config,
&runtime,
&provider,
"bcode.example",
"browser",
EnrollmentDestination {
profile: Some("new-account".to_owned()),
..Default::default()
},
)
.is_err()
);
}
}