Menu
Public repository / master
AkurAI-Drive / backend/src/config.rs
Latest change beff27fc3a062a220cde556e7bb28da49f6d04ea - Drive login: recover oidc.rs (IdP PKCE + introspection) from deployed binary by AkurAI Agent
use std::sync::LazyLock;
/// Process configuration, loaded once from the environment.
///
/// Everything is read from env via `std::env::var` — no secrets in code. On the
/// box these are injected by systemd from `EnvironmentFile=/etc/akurai-drive-api.env`.
/// The `aws` CLI inherits its credentials from the process environment directly; the
/// SigV4 presigner (`sigv4.rs`) is in-process and signs locally, so it also needs
/// `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` read into the config here.
pub struct Config {
pub port: u16,
pub listen_addr: String,
pub db_path: String,
/// Plain HTTP, same box, no TLS — used for back-channel /token and /userinfo.
pub idp_internal_url: String,
/// Public issuer — used to build the browser-facing /authorize redirect.
pub idp_issuer: String,
pub oidc_client_id: String,
pub oidc_client_secret: String,
pub oidc_redirect_uri: String,
/// Groups allowed to sign in (`AKURAI_IDP_ALLOWED_GROUPS`, comma separated).
/// Empty means every IdP identity may sign in.
pub allowed_groups: Vec<String>,
pub drive_public_url: String,
pub drive_access_code: String,
pub drive_admin_group: String,
pub drive_bucket_prefix: String,
pub aws_region: String,
/// Used by the in-process SigV4 presigner (and shared with the `aws` CLI via env).
pub aws_access_key_id: String,
pub aws_secret_access_key: String,
/// Audience the IdP puts in access tokens for this app (MCP resource server).
pub mcp_audience: String,
/// Stable deployment identifier used by the Framework telemetry contract.
pub drive_installation_id: String,
}
static CONFIG: LazyLock<Config> = LazyLock::new(|| {
let port = std::env::var("DRIVE_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8096u16);
Config {
port,
listen_addr: format!("127.0.0.1:{port}"),
db_path: std::env::var("DRIVE_DB_PATH")
.unwrap_or_else(|_| "./data/drive.sqlite".to_string()),
idp_internal_url: env_first(&["AKURAI_IDP_INTERNAL_URL", "IDP_INTERNAL_URL"])
.unwrap_or_else(|| "http://127.0.0.1:3500".to_string())
.trim_end_matches('/')
.to_string(),
idp_issuer: env_first(&["AKURAI_IDP_ISSUER", "IDP_ISSUER"])
.unwrap_or_else(|| "https://auth.olibuijr.com".to_string())
.trim_end_matches('/')
.to_string(),
oidc_client_id: env_first(&["AKURAI_IDP_CLIENT_ID", "OIDC_CLIENT_ID"]).unwrap_or_default(),
oidc_client_secret: env_first(&["AKURAI_IDP_CLIENT_SECRET", "OIDC_CLIENT_SECRET"])
.unwrap_or_default(),
oidc_redirect_uri: env_first(&["AKURAI_IDP_REDIRECT_URI", "OIDC_REDIRECT_URI"])
.unwrap_or_else(|| "https://akurai-drive.olibuijr.com/auth/callback".to_string()),
allowed_groups: parse_list(&std::env::var("AKURAI_IDP_ALLOWED_GROUPS").unwrap_or_default()),
drive_public_url: env_first(&["AKURAI_PUBLIC_URL", "DRIVE_PUBLIC_URL"])
.unwrap_or_else(|| "https://akurai-drive.olibuijr.com".to_string())
.trim_end_matches('/')
.to_string(),
drive_access_code: std::env::var("DRIVE_ACCESS_CODE").unwrap_or_default(),
drive_admin_group: std::env::var("DRIVE_ADMIN_GROUP")
.unwrap_or_else(|_| "drive-admins".to_string()),
drive_bucket_prefix: std::env::var("DRIVE_BUCKET_PREFIX")
.unwrap_or_else(|_| "akurai-drive".to_string()),
aws_region: std::env::var("AWS_DEFAULT_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
aws_access_key_id: std::env::var("AWS_ACCESS_KEY_ID").unwrap_or_default(),
aws_secret_access_key: std::env::var("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
mcp_audience: std::env::var("MCP_AUDIENCE").unwrap_or_else(|_| "akurai-drive".to_string()),
drive_installation_id: std::env::var("DRIVE_INSTALLATION_ID")
.unwrap_or_else(|_| "akurai-drive-api".to_string()),
}
});
pub fn get() -> &'static Config {
&CONFIG
}
/// First non-empty value among `names`. The deployed service uses the shared
/// `AKURAI_IDP_*` / `AKURAI_PUBLIC_URL` names; the pre-SSO names are still read
/// as fallbacks so an older env file keeps working.
fn env_first(names: &[&str]) -> Option<String> {
first_non_empty(names.iter().map(|n| std::env::var(n).ok()))
}
fn first_non_empty(values: impl IntoIterator<Item = Option<String>>) -> Option<String> {
values
.into_iter()
.flatten()
.map(|v| v.trim().to_string())
.find(|v| !v.is_empty())
}
/// Split a comma-separated list, dropping blanks.
pub fn parse_list(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
/// Env settings the service cannot run without, reported by their primary name.
pub fn missing_required(cfg: &Config) -> Vec<&'static str> {
[
("AKURAI_IDP_CLIENT_ID", cfg.oidc_client_id.is_empty()),
(
"AKURAI_IDP_CLIENT_SECRET",
cfg.oidc_client_secret.is_empty(),
),
]
.into_iter()
.filter_map(|(name, missing)| missing.then_some(name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_non_empty_prefers_earliest_set_value() {
let got = first_non_empty([None, Some(" ".into()), Some("b".into()), Some("c".into())]);
assert_eq!(got.as_deref(), Some("b"));
assert_eq!(first_non_empty([None, Some(String::new())]), None);
}
#[test]
fn parse_list_splits_and_trims() {
assert_eq!(parse_list(" a, ,b ,c"), vec!["a", "b", "c"]);
assert!(parse_list("").is_empty());
}
}