Menu
Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb
AkurAI-CRM / crates/crm-api/src/auth/idp_client.rs
Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson
//! Back-channel client for the AkurAI IdP (token, introspect, userinfo).
//!
//! Configuration reads `AKURAI_IDP_*`; the older `CRM_IDP_*` names are
//! fallbacks so existing environments keep working.
use akurai_json::Value;
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
pub const DEFAULT_ISSUER: &str = "https://auth.olibuijr.com";
pub const DEFAULT_INTERNAL_URL: &str = "http://127.0.0.1:3500";
pub const DEFAULT_PUBLIC_URL: &str = "https://akurai-crm.olibuijr.com";
pub const SCOPE: &str = "openid profile email groups";
#[derive(Clone, Debug)]
pub struct Config {
pub client_id: String,
client_secret: String,
pub issuer: String,
pub redirect_uri: String,
pub internal: InternalUrl,
pub allowed_groups: Vec<String>,
pub public_url: String,
}
/// Plain-HTTP base URL of the IdP on the private network.
#[derive(Clone, Debug, PartialEq)]
pub struct InternalUrl {
pub host: String,
pub port: u16,
pub base_path: String,
}
impl InternalUrl {
pub fn parse(url: &str) -> Result<Self, String> {
let rest = url
.strip_prefix("http://")
.ok_or_else(|| "AKURAI_IDP_INTERNAL_URL must use http://".to_string())?;
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => (
host,
port.parse::<u16>()
.ok()
.filter(|p| *p != 0)
.ok_or_else(|| "AKURAI_IDP_INTERNAL_URL has an invalid port".to_string())?,
),
None => (authority, 80),
};
if host.is_empty() {
return Err("AKURAI_IDP_INTERNAL_URL has no host".into());
}
let path = path.trim_end_matches('/');
Ok(Self {
host: host.to_string(),
port,
base_path: if path.is_empty() {
String::new()
} else {
format!("/{path}")
},
})
}
}
/// Raw values as read from the environment.
#[derive(Default)]
pub struct Settings {
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub issuer: Option<String>,
pub redirect_uri: Option<String>,
pub internal_url: Option<String>,
pub allowed_groups: Option<String>,
pub public_url: Option<String>,
}
impl Settings {
pub fn from_env() -> Self {
let read = |names: &[&str]| {
names
.iter()
.find_map(|name| std::env::var(name).ok().filter(|v| !v.trim().is_empty()))
};
let legacy_internal = match (
read(&["CRM_IDP_INTERNAL_HOST"]),
read(&["CRM_IDP_INTERNAL_PORT"]),
) {
(Some(host), Some(port)) => Some(format!("http://{host}:{port}")),
_ => None,
};
Self {
client_id: read(&["AKURAI_IDP_CLIENT_ID", "CRM_IDP_CLIENT_ID"]),
client_secret: read(&["AKURAI_IDP_CLIENT_SECRET", "CRM_IDP_CLIENT_SECRET"]),
issuer: read(&["AKURAI_IDP_ISSUER", "CRM_IDP_ISSUER"]),
redirect_uri: read(&["AKURAI_IDP_REDIRECT_URI", "CRM_IDP_REDIRECT_URI"]),
internal_url: read(&["AKURAI_IDP_INTERNAL_URL"]).or(legacy_internal),
allowed_groups: read(&["AKURAI_IDP_ALLOWED_GROUPS", "CRM_IDP_ALLOWED_GROUPS"]),
public_url: read(&["AKURAI_PUBLIC_URL"]),
}
}
}
impl Config {
pub fn from_env() -> Result<Self, String> {
Self::from_settings(Settings::from_env())
}
pub fn from_settings(settings: Settings) -> Result<Self, String> {
let required = |value: Option<String>, name: &str| -> Result<String, String> {
let value = value.ok_or_else(|| format!("{name} is not configured"))?;
validate_value(name, &value)?;
Ok(value)
};
let client_id = required(settings.client_id, "AKURAI_IDP_CLIENT_ID")?;
let client_secret = required(settings.client_secret, "AKURAI_IDP_CLIENT_SECRET")?;
let redirect_uri = required(settings.redirect_uri, "AKURAI_IDP_REDIRECT_URI")?;
require_http_url("AKURAI_IDP_REDIRECT_URI", &redirect_uri)?;
let issuer = settings
.issuer
.unwrap_or_else(|| DEFAULT_ISSUER.to_string())
.trim_end_matches('/')
.to_string();
validate_value("AKURAI_IDP_ISSUER", &issuer)?;
require_http_url("AKURAI_IDP_ISSUER", &issuer)?;
let internal = InternalUrl::parse(
settings
.internal_url
.as_deref()
.unwrap_or(DEFAULT_INTERNAL_URL),
)?;
let public_url = settings
.public_url
.unwrap_or_else(|| DEFAULT_PUBLIC_URL.to_string())
.trim_end_matches('/')
.to_string();
Ok(Self {
client_id,
client_secret,
issuer,
redirect_uri,
internal,
allowed_groups: parse_groups(settings.allowed_groups.as_deref()),
public_url,
})
}
pub fn authorization_url(&self, state: &str, nonce: &str, code_challenge: &str) -> String {
format!(
"{}/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}&code_challenge={}&code_challenge_method=S256",
self.issuer,
urlencode(&self.client_id),
urlencode(&self.redirect_uri),
urlencode(SCOPE),
urlencode(state),
urlencode(nonce),
urlencode(code_challenge),
)
}
/// The IdP's session logout endpoint (takes no parameters).
pub fn logout_url(&self) -> String {
format!("{}/logout", self.issuer)
}
fn basic_auth(&self) -> String {
format!(
"Basic {}",
STANDARD.encode(format!("{}:{}", self.client_id, self.client_secret))
)
}
fn path(&self, endpoint: &str) -> String {
format!("{}{endpoint}", self.internal.base_path)
}
/// Exchange an authorization code (with its PKCE verifier) for tokens.
pub fn exchange_code(&self, code: &str, code_verifier: &str) -> Result<Tokens, String> {
let body = format!(
"grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}",
urlencode(code),
urlencode(&self.redirect_uri),
urlencode(code_verifier),
);
let response = self
.post_form("/token", &body)
.map_err(|e| format!("code exchange failed: {e}"))?;
let json = akurai_json::parse(&response).map_err(|e| format!("token parse: {e}"))?;
let field = |name: &str| json.get(name).and_then(Value::as_str).map(str::to_string);
Ok(Tokens {
access_token: field("access_token")
.ok_or_else(|| "token response has no access_token".to_string())?,
id_token: field("id_token")
.ok_or_else(|| "token response has no id_token".to_string())?,
})
}
/// RFC 7662 introspection; returns the parsed response.
pub fn introspect(&self, access_token: &str) -> Result<Value, String> {
let body = format!(
"token={}&token_type_hint=access_token",
urlencode(access_token)
);
let response = self
.post_form("/introspect", &body)
.map_err(|e| format!("introspection failed: {e}"))?;
akurai_json::parse(&response).map_err(|e| format!("introspect parse: {e}"))
}
pub fn userinfo(&self, access_token: &str) -> Result<UserInfo, String> {
fetch_userinfo(&self.internal, access_token)
}
fn post_form(&self, endpoint: &str, body: &str) -> Result<String, String> {
send_http(
&self.internal,
&format!(
"POST {} HTTP/1.1\r\nHost: {}\r\nAuthorization: {}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
self.path(endpoint),
self.internal.host,
self.basic_auth(),
body.len(),
),
)
}
}
pub struct Tokens {
pub access_token: String,
pub id_token: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct UserInfo {
pub sub: String,
pub email: String,
pub tenant_id: String,
pub groups: Vec<String>,
}
impl UserInfo {
pub fn parse(body: &str) -> Result<Self, String> {
let info = akurai_json::parse(body).map_err(|e| format!("userinfo parse: {e}"))?;
let text = |name: &str| {
info.get(name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let groups = match info.get("groups") {
Some(Value::Array(items)) => items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
_ => Vec::new(),
};
Ok(Self {
sub: text("sub").ok_or_else(|| "userinfo has no sub".to_string())?,
email: text("email").unwrap_or_default(),
tenant_id: text("organization_id")
.or_else(|| text("tenant_id"))
.or_else(|| text("tenantId"))
.ok_or_else(|| "userinfo has no organization_id or tenant_id".to_string())?,
groups,
})
}
}
/// `GET /userinfo` with a bearer access token (returns `groups`).
pub fn fetch_userinfo(internal: &InternalUrl, access_token: &str) -> Result<UserInfo, String> {
let response = send_http(
internal,
&format!(
"GET {}/userinfo HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {access_token}\r\nConnection: close\r\n\r\n",
internal.base_path, internal.host,
),
)
.map_err(|e| format!("userinfo failed: {e}"))?;
UserInfo::parse(&response)
}
/// Internal IdP URL from `AKURAI_IDP_INTERNAL_URL` (legacy host/port
/// fallback, then the default).
pub fn internal_url_from_env() -> Result<InternalUrl, String> {
InternalUrl::parse(
Settings::from_env()
.internal_url
.as_deref()
.unwrap_or(DEFAULT_INTERNAL_URL),
)
}
/// `AKURAI_PUBLIC_URL` without a trailing slash (defaults to production).
pub fn public_url_from_env() -> String {
Settings::from_env()
.public_url
.unwrap_or_else(|| DEFAULT_PUBLIC_URL.to_string())
.trim_end_matches('/')
.to_string()
}
/// `AKURAI_IDP_ALLOWED_GROUPS` (legacy `CRM_IDP_ALLOWED_GROUPS`).
pub fn allowed_groups_from_env() -> Vec<String> {
parse_groups(Settings::from_env().allowed_groups.as_deref())
}
/// Comma- or whitespace-separated group names.
pub fn parse_groups(value: Option<&str>) -> Vec<String> {
value
.unwrap_or("")
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|g| !g.is_empty())
.map(str::to_string)
.collect()
}
/// An empty allow-list admits everyone; otherwise one group must match.
pub fn groups_allowed(allowed: &[String], groups: &[String]) -> bool {
allowed.is_empty() || groups.iter().any(|g| allowed.contains(g))
}
pub const GROUP_DENIED: &str = "your account is not a member of a group allowed in AkurAI-CRM";
fn require_http_url(name: &str, value: &str) -> Result<(), String> {
if value.starts_with("http://") || value.starts_with("https://") {
Ok(())
} else {
Err(format!("{name} must use http:// or https://"))
}
}
fn validate_value(name: &str, value: &str) -> Result<(), String> {
if value.trim().is_empty() {
return Err(format!("{name} is not configured"));
}
if value.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err(format!("{name} contains invalid whitespace"));
}
Ok(())
}
fn send_http(target: &InternalUrl, request: &str) -> Result<String, String> {
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let addr = (target.host.as_str(), target.port)
.to_socket_addrs()
.map_err(|e| format!("resolve {}: {e}", target.host))?
.next()
.ok_or_else(|| format!("resolve {}: no address", target.host))?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))
.map_err(|e| format!("connect {addr}: {e}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.map_err(|e| format!("set timeout: {e}"))?;
stream
.write_all(request.as_bytes())
.map_err(|e| format!("write: {e}"))?;
let mut buf = Vec::new();
stream
.read_to_end(&mut buf)
.map_err(|e| format!("read: {e}"))?;
let response = String::from_utf8_lossy(&buf);
let status = response
.lines()
.next()
.and_then(|line| line.split(' ').nth(1))
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
let body = response
.split_once("\r\n\r\n")
.map(|(_, body)| body.to_string())
.unwrap_or_default();
if !(200..300).contains(&status) {
return Err(format!(
"IdP returned HTTP {status}: {}",
body.chars().take(200).collect::<String>()
));
}
Ok(body)
}
pub fn urlencode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(b as char)
}
_ => result.push_str(&format!("%{b:02X}")),
}
}
result
}
pub fn urldecode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(byte) => {
out.push(byte);
i += 2;
}
Err(_) => out.push(b'%'),
}
}
byte => out.push(byte),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
pub fn test_settings(internal_url: &str) -> Settings {
Settings {
client_id: Some("akurai-crm".into()),
client_secret: Some("test-secret".into()),
issuer: Some("https://auth.test".into()),
redirect_uri: Some("https://crm.test/auth/callback".into()),
internal_url: Some(internal_url.into()),
allowed_groups: None,
public_url: Some("https://crm.test".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_rejects_missing_secret() {
let settings = Settings {
client_secret: None,
..test_settings("http://127.0.0.1:3500")
};
let error = Config::from_settings(settings).unwrap_err();
assert_eq!(error, "AKURAI_IDP_CLIENT_SECRET is not configured");
}
#[test]
fn config_defaults_issuer_and_internal_url() {
let config = Config::from_settings(Settings {
issuer: None,
internal_url: None,
..test_settings("")
})
.unwrap();
assert_eq!(config.issuer, DEFAULT_ISSUER);
assert_eq!(config.internal.host, "127.0.0.1");
assert_eq!(config.internal.port, 3500);
}
#[test]
fn internal_url_parses_host_port_and_path() {
let url = InternalUrl::parse("http://idp.lan:8080/base/").unwrap();
assert_eq!(
url,
InternalUrl {
host: "idp.lan".into(),
port: 8080,
base_path: "/base".into()
}
);
assert!(InternalUrl::parse("https://idp.lan").is_err());
}
#[test]
fn authorization_url_carries_pkce_nonce_and_state() {
let config = Config::from_settings(test_settings("http://127.0.0.1:1")).unwrap();
let url = config.authorization_url("st", "no", "ch");
assert!(
url.starts_with("https://auth.test/authorize?response_type=code&client_id=akurai-crm&")
);
assert!(url.contains("&scope=openid%20profile%20email%20groups&"));
assert!(url.contains("&state=st&nonce=no&code_challenge=ch&code_challenge_method=S256"));
assert!(!url.contains("test-secret"));
}
#[test]
fn userinfo_prefers_organization_and_reads_groups() {
let info = UserInfo::parse(
r#"{"sub":"u1","email":"a@b","tenant_id":"t-old","organization_id":"org-1","groups":["crm","x"]}"#,
)
.unwrap();
assert_eq!(info.tenant_id, "org-1");
assert_eq!(info.groups, vec!["crm".to_string(), "x".to_string()]);
assert_eq!(
UserInfo::parse(r#"{"sub":"u1"}"#).unwrap_err(),
"userinfo has no organization_id or tenant_id"
);
}
#[test]
fn group_policy() {
let allowed = parse_groups(Some("crm-users, admins"));
assert_eq!(allowed, vec!["crm-users".to_string(), "admins".to_string()]);
assert!(groups_allowed(&allowed, &["admins".into()]));
assert!(!groups_allowed(&allowed, &["other".into()]));
assert!(groups_allowed(&[], &[]));
}
#[test]
fn urldecode_roundtrips() {
assert_eq!(urldecode(&urlencode("/a b?c=d&e")), "/a b?c=d&e");
assert_eq!(urldecode("100%"), "100%");
}
}