use base64::Engine as _;
use serde_json::{Value, json};
use crate::{AuthPolicy, AuthProvider, BigQueryNaming, ConsolePolicy, RuntimeDecisionState};
pub fn parse_console_auth_config(value: &Value) -> Result<RuntimeDecisionState, String> {
let object = value
.as_object()
.ok_or("auth_config must be a JSON object")?;
let provider = object
.get("provider")
.and_then(Value::as_str)
.or_else(|| {
(object.contains_key("shared_secret") || object.contains_key("sharedSecret"))
.then_some("jwt")
})
.ok_or("auth_config.provider is required")?;
if provider == "oidc" {
return parse_oidc_auth_config(object);
}
if provider != "jwt" {
return Err(format!("unsupported auth_config.provider '{provider}'"));
}
let secret = object
.get("shared_secret")
.or_else(|| object.get("sharedSecret"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or("auth_config.shared_secret must be a non-empty string")?;
let issuer = object
.get("issuer")
.and_then(Value::as_str)
.unwrap_or("http://127.0.0.1/mobkit-gateway");
let audience = object
.get("audience")
.and_then(Value::as_str)
.unwrap_or("persistent-gateway");
let allowlist = object
.get("email_allowlist")
.or_else(|| object.get("emailAllowlist"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect()
})
.unwrap_or_default();
let mut decisions = RuntimeDecisionState::local_console(
ConsolePolicy {
require_app_auth: true,
..ConsolePolicy::default()
},
Some(BigQueryNaming {
dataset: "default_dataset".to_string(),
table: "default_table".to_string(),
}),
);
decisions.auth = AuthPolicy {
default_provider: AuthProvider::GenericOidc,
email_allowlist: allowlist,
};
decisions.trusted_oidc = crate::TrustedOidcRuntimeConfig {
discovery_json:
json!({"issuer":issuer,"jwks_uri":"http://127.0.0.1/mobkit-gateway/jwks.json"})
.to_string(),
jwks_json: json!({"keys":[{"kty":"oct","alg":"HS256",
"k":base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret.as_bytes())}]})
.to_string(),
audience: audience.to_string(),
require_verified_email: false,
};
Ok(decisions)
}
fn parse_oidc_auth_config(
object: &serde_json::Map<String, Value>,
) -> Result<RuntimeDecisionState, String> {
let issuer = object
.get("issuer")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or("auth_config.issuer must be a non-empty string for provider \"oidc\"")?;
let audience = object
.get("audience")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or("auth_config.audience must be a non-empty string for provider \"oidc\"")?;
let (jwks_json, jwks_uri) = match (object.get("jwks_json"), object.get("jwks_uri")) {
(Some(_), Some(_)) => {
return Err("auth_config accepts exactly one of jwks_json or jwks_uri".to_string());
}
(Some(inline), None) => {
let document = match inline {
Value::String(text) => serde_json::from_str::<Value>(text)
.map_err(|error| format!("auth_config.jwks_json is not valid JSON: {error}"))?,
other => other.clone(),
};
validate_jwks_document(&document)?;
(document.to_string(), OIDC_INLINE_JWKS_MARKER.to_string())
}
(None, Some(uri)) => {
let uri = uri
.as_str()
.map(str::trim)
.filter(|value| value.starts_with("https://"))
.ok_or("auth_config.jwks_uri must be an https URL")?;
let document = fetch_jwks_snapshot(uri)?;
validate_jwks_document(&document)?;
(document.to_string(), uri.to_string())
}
(None, None) => {
return Err(
"auth_config requires jwks_json or jwks_uri for provider \"oidc\"".to_string(),
);
}
};
let allowlist: Vec<String> = object
.get("email_allowlist")
.or_else(|| object.get("emailAllowlist"))
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect()
})
.unwrap_or_default();
if allowlist.is_empty() {
return Err(
"auth_config.email_allowlist must name at least one email for provider \"oidc\""
.to_string(),
);
}
let mut decisions = RuntimeDecisionState::local_console(
ConsolePolicy {
require_app_auth: true,
..ConsolePolicy::default()
},
Some(BigQueryNaming {
dataset: "default_dataset".to_string(),
table: "default_table".to_string(),
}),
);
decisions.auth = AuthPolicy {
default_provider: AuthProvider::GenericOidc,
email_allowlist: allowlist,
};
decisions.trusted_oidc = crate::TrustedOidcRuntimeConfig {
discovery_json: json!({"issuer": issuer, "jwks_uri": jwks_uri}).to_string(),
jwks_json,
audience: audience.to_string(),
require_verified_email: true,
};
Ok(decisions)
}
pub const OIDC_INLINE_JWKS_MARKER: &str = "inline";
fn validate_jwks_document(document: &Value) -> Result<(), String> {
let keys = document
.get("keys")
.and_then(Value::as_array)
.ok_or("auth_config JWKS must be an object with a keys array")?;
if keys.is_empty() {
return Err(
"auth_config JWKS carries no keys; the console would refuse every request".to_string(),
);
}
for key in keys {
let kty = key.get("kty").and_then(Value::as_str).unwrap_or_default();
match kty {
"RSA" | "EC" => {}
"oct" => {
return Err("auth_config provider \"oidc\" does not accept symmetric (oct) keys; use provider \"jwt\" for a shared secret".to_string());
}
other => {
return Err(format!(
"auth_config JWKS key type '{other}' is not supported"
));
}
}
}
Ok(())
}
fn fetch_jwks_snapshot(uri: &str) -> Result<Value, String> {
let uri = uri.to_string();
std::thread::scope(|scope| {
scope
.spawn(|| {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|error| format!("auth_config.jwks_uri client failed: {error}"))?;
let response = client
.get(&uri)
.send()
.map_err(|error| format!("auth_config.jwks_uri fetch failed: {error}"))?
.error_for_status()
.map_err(|error| format!("auth_config.jwks_uri fetch failed: {error}"))?;
response
.json::<Value>()
.map_err(|error| format!("auth_config.jwks_uri returned invalid JSON: {error}"))
})
.join()
.map_err(|_| "auth_config.jwks_uri fetch thread panicked".to_string())?
})
}