Menu
Public repository / 7874b42d5ef0d2c4e7854be0760da60e7d1f5ef2
AkurAI-CRM / crates/crm-api/src/auth/idp_client.rs
Latest change c7bf22ef78ad08f1f59b04d08e88d86e4ca91815 - feat: add tenant-safe account graph by Ólafur Búi Ólafsson
#[derive(Clone)]
pub struct Config {
client_id: String,
client_secret: String,
redirect_uri: String,
internal_host: String,
internal_port: u16,
}
impl Config {
pub fn from_env() -> Result<Self, String> {
Self::from_values(
required_env("CRM_IDP_CLIENT_ID")?,
required_env("CRM_IDP_CLIENT_SECRET")?,
required_env("CRM_IDP_REDIRECT_URI")?,
required_env("CRM_IDP_INTERNAL_HOST")?,
required_env("CRM_IDP_INTERNAL_PORT")?,
)
}
fn from_values(
client_id: String,
client_secret: String,
redirect_uri: String,
internal_host: String,
internal_port: String,
) -> Result<Self, String> {
validate_value("CRM_IDP_CLIENT_ID", &client_id)?;
validate_value("CRM_IDP_CLIENT_SECRET", &client_secret)?;
validate_value("CRM_IDP_REDIRECT_URI", &redirect_uri)?;
if !(redirect_uri.starts_with("http://") || redirect_uri.starts_with("https://")) {
return Err("CRM_IDP_REDIRECT_URI must use http:// or https://".to_string());
}
validate_value("CRM_IDP_INTERNAL_HOST", &internal_host)?;
let internal_port = internal_port
.parse::<u16>()
.map_err(|_| "CRM_IDP_INTERNAL_PORT must be a valid TCP port".to_string())?;
if internal_port == 0 {
return Err("CRM_IDP_INTERNAL_PORT must be a valid TCP port".to_string());
}
Ok(Self {
client_id,
client_secret,
redirect_uri,
internal_host,
internal_port,
})
}
pub fn authorization_url(&self, state: &str) -> String {
format!(
"https://auth.olibuijr.com/authorize?client_id={}&redirect_uri={}&response_type=code&scope=openid+profile+email+groups&state={}",
urlencode(&self.client_id),
urlencode(&self.redirect_uri),
urlencode(state),
)
}
}
fn required_env(name: &str) -> Result<String, String> {
std::env::var(name).map_err(|_| format!("{name} is not configured"))
}
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(|character| character.is_control() || character.is_whitespace())
{
return Err(format!("{name} contains invalid whitespace"));
}
Ok(())
}
#[cfg(test)]
fn test_config() -> Config {
Config::from_values(
"client-id".to_string(),
"test-secret".to_string(),
"https://crm.test/auth/callback".to_string(),
"127.0.0.1".to_string(),
"3500".to_string(),
)
.unwrap()
}
pub struct UserInfo {
pub sub: String,
pub email: String,
pub tenant_id: String,
}
pub fn exchange_code(code: &str, _state: &str) -> Result<UserInfo, String> {
let config = Config::from_env()?;
let basic = base64_encode(&format!("{}:{}", config.client_id, config.client_secret));
let token_body = format!(
"grant_type=authorization_code&code={}&redirect_uri={}",
urlencode(code),
urlencode(&config.redirect_uri),
);
let response = http_post(
&config.internal_host,
config.internal_port,
"/token",
&[
("Authorization", &format!("Basic {}", basic)),
("Content-Type", "application/x-www-form-urlencoded"),
],
&token_body,
)?;
let token_json = akurai_json::parse(&response.body).map_err(|e| format!("token parse: {e}"))?;
let access_token = token_json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "no access_token in token response".to_string())?
.to_string();
let userinfo_response = http_get(
&config.internal_host,
config.internal_port,
"/userinfo",
&[("Authorization", &format!("Bearer {}", access_token))],
)?;
let info =
akurai_json::parse(&userinfo_response.body).map_err(|e| format!("userinfo parse: {e}"))?;
let sub = info
.get("sub")
.and_then(|v| v.as_str())
.ok_or_else(|| "no sub in userinfo".to_string())?
.to_string();
let email = info
.get("email")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let tenant_id = info
.get("tenantId")
.or_else(|| info.get("tenant_id"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or_else(|| "no tenantId in userinfo".to_string())?
.to_string();
Ok(UserInfo {
sub,
email,
tenant_id,
})
}
#[allow(dead_code)]
struct HttpResponse {
status: u16,
body: String,
}
fn http_get(
host: &str,
port: u16,
path: &str,
headers: &[(&str, &str)],
) -> Result<HttpResponse, String> {
let mut req = format!("GET {} HTTP/1.1\r\nHost: {}:{}\r\n", path, host, port);
for (k, v) in headers {
req.push_str(&format!("{}: {}\r\n", k, v));
}
req.push_str("Connection: close\r\n\r\n");
send_http(host, port, &req)
}
fn http_post(
host: &str,
port: u16,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> Result<HttpResponse, String> {
let mut req = format!(
"POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Length: {}\r\n",
path,
host,
port,
body.len()
);
for (k, v) in headers {
req.push_str(&format!("{}: {}\r\n", k, v));
}
req.push_str("Connection: close\r\n\r\n");
req.push_str(body);
send_http(host, port, &req)
}
fn send_http(host: &str, port: u16, request: &str) -> Result<HttpResponse, String> {
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
let addr = format!("{}:{}", host, port);
let stream = TcpStream::connect_timeout(
&addr.parse().map_err(|e| format!("addr parse: {e}"))?,
Duration::from_secs(5),
)
.map_err(|e| format!("connect: {e}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.map_err(|e| format!("set timeout: {e}"))?;
let mut wr = &stream;
wr.write_all(request.as_bytes())
.map_err(|e| format!("write: {e}"))?;
let mut buf = Vec::new();
let mut rd = &stream;
rd.read_to_end(&mut buf).map_err(|e| format!("read: {e}"))?;
let response = String::from_utf8_lossy(&buf).to_string();
let status = response
.lines()
.next()
.and_then(|line| line.split(' ').nth(1))
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
let body = if let Some(pos) = response.find("\r\n\r\n") {
response[pos + 4..].to_string()
} else {
String::new()
};
if !(200..300).contains(&status) {
return Err(format!(
"HTTP {}: {}",
status,
body.chars().take(200).collect::<String>()
));
}
Ok(HttpResponse { status, body })
}
fn base64_encode(input: &str) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = input.as_bytes();
let mut result = String::new();
for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let triple = (b0 << 16) | (b1 << 8) | b2;
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(CHARS[(triple & 0x3F) as usize] as char);
} else {
result.push('=');
}
}
result
}
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);
}
b' ' => result.push('+'),
_ => result.push_str(&format!("%{:02X}", b)),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_rejects_missing_secret() {
let error = match Config::from_values(
"client-id".into(),
"".into(),
"https://crm.test/callback".into(),
"127.0.0.1".into(),
"3500".into(),
) {
Ok(_) => panic!("missing secret must fail"),
Err(error) => error,
};
assert!(error.contains("CRM_IDP_CLIENT_SECRET"));
}
#[test]
fn config_encodes_authorization_parameters() {
let config = test_config();
let url = config.authorization_url("state value");
assert!(url.contains("client-id"));
assert!(url.contains("state+value"));
assert!(!url.contains("test-secret"));
}
}