Menu
akurai-tasks
publicLatest change 350a37d39889e09d093509a69e7f06106958ffa2 - Harden task coordination and recovery by Ólafur Búi Ólafsson
use akurai_json::{parse, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct OidcConfig {
pub issuer: String,
pub internal_url: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub allowed_emails: Vec<String>,
}
#[derive(Clone)]
pub(crate) struct Pending {
pub verifier: String,
pub nonce: String,
pub expires_at: i64,
pub completed: Option<Identity>,
}
#[derive(Clone)]
pub(crate) struct Identity {
pub sub: String,
pub email: String,
}
pub(crate) fn begin(
config: &OidcConfig,
pending: &mut BTreeMap<String, Pending>,
) -> Result<String, String> {
if config.client_id.is_empty() || config.client_secret.is_empty() {
return Err("OIDC client is not configured".into());
}
let state = random_token(24)?;
let verifier = random_token(32)?;
let nonce = random_token(24)?;
let challenge = base64url(&Sha256::digest(verifier.as_bytes()));
pending.retain(|_, value| value.expires_at > now());
pending.insert(
state.clone(),
Pending {
verifier,
nonce: nonce.clone(),
expires_at: now() + 300,
completed: None,
},
);
Ok(format!(
"{}/authorize?client_id={}&redirect_uri={}&response_type=code&scope=openid%20profile%20email&state={}&nonce={}&code_challenge={}&code_challenge_method=S256",
config.issuer.trim_end_matches('/'), encode(&config.client_id), encode(&config.redirect_uri), encode(&state), encode(&nonce), encode(&challenge)
))
}
pub(crate) fn callback(
config: &OidcConfig,
query: Option<&str>,
pending: &mut BTreeMap<String, Pending>,
) -> Result<Identity, String> {
let params = form(query.unwrap_or(""));
let state = params.get("state").ok_or("OIDC state missing")?;
let code = params
.get("code")
.ok_or("OIDC authorization code missing")?;
let flow = pending.get_mut(state).ok_or("OIDC state is invalid")?;
if flow.expires_at <= now() {
return Err("OIDC state expired".into());
}
if let Some(identity) = &flow.completed {
return Ok(identity.clone());
}
let token_body = format!("grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&client_secret={}&code_verifier={}", encode(code), encode(&config.redirect_uri), encode(&config.client_id), encode(&config.client_secret), encode(&flow.verifier));
let token_response = request(
&config.internal_url,
"POST",
"/token",
&[("Content-Type", "application/x-www-form-urlencoded")],
Some(&token_body),
)?;
let token = parse(&token_response).map_err(|error| format!("OIDC token response: {error}"))?;
let access = token
.get("access_token")
.and_then(Value::as_str)
.ok_or("OIDC access token missing")?;
let id_token = token
.get("id_token")
.and_then(Value::as_str)
.ok_or("OIDC ID token missing")?;
validate_nonce(id_token, &flow.nonce)?;
let userinfo = request(
&config.internal_url,
"GET",
"/userinfo",
&[("Authorization", &format!("Bearer {access}"))],
None,
)?;
let user = parse(&userinfo).map_err(|error| format!("OIDC userinfo response: {error}"))?;
let sub = user
.get("sub")
.and_then(Value::as_str)
.ok_or("OIDC subject missing")?
.to_string();
let email = user
.get("email")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if !config.allowed_emails.is_empty()
&& !config
.allowed_emails
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(&email))
{
return Err("OIDC identity is not allowed".into());
}
let identity = Identity { sub, email };
flow.completed = Some(identity.clone());
Ok(identity)
}
fn validate_nonce(token: &str, expected: &str) -> Result<(), String> {
let payload = token
.split('.')
.nth(1)
.ok_or("OIDC ID token is malformed")?;
let bytes = base64url_decode(payload)?;
let json = parse(&String::from_utf8(bytes).map_err(|_| "OIDC ID token payload is not UTF-8")?)
.map_err(|error| format!("OIDC ID token payload: {error}"))?;
if json.get("nonce").and_then(Value::as_str) != Some(expected) {
return Err("OIDC nonce mismatch".into());
}
Ok(())
}
fn request(
base: &str,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&str>,
) -> Result<String, String> {
let without_scheme = base
.strip_prefix("http://")
.ok_or("OIDC internal URL must use loopback HTTP")?;
let (host, port) = without_scheme
.split_once(':')
.unwrap_or((without_scheme, "80"));
if !matches!(host, "127.0.0.1" | "localhost") {
return Err("OIDC internal URL must be loopback".into());
}
let port: u16 = port.parse().map_err(|_| "OIDC internal port is invalid")?;
let mut stream = TcpStream::connect_timeout(
&format!("{host}:{port}")
.parse()
.map_err(|_| "OIDC internal address invalid")?,
Duration::from_secs(5),
)
.map_err(|error| format!("OIDC connect: {error}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.map_err(|error| format!("OIDC timeout: {error}"))?;
let body = body.unwrap_or("");
let mut wire = format!("{method} {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\nContent-Length: {}\r\n", body.len());
for (name, value) in headers {
wire.push_str(&format!("{name}: {value}\r\n"));
}
wire.push_str("\r\n");
wire.push_str(body);
stream
.write_all(wire.as_bytes())
.map_err(|error| format!("OIDC write: {error}"))?;
let mut bytes = Vec::new();
stream
.read_to_end(&mut bytes)
.map_err(|error| format!("OIDC read: {error}"))?;
let response = String::from_utf8_lossy(&bytes);
let status = response
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(0);
let content = response
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if !(200..300).contains(&status) {
return Err(format!("OIDC endpoint returned HTTP {status}"));
}
Ok(content.to_string())
}
fn form(query: &str) -> BTreeMap<String, String> {
query
.split('&')
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (decode(key), decode(value)))
.collect()
}
fn encode(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
fn decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16) {
out.push(byte);
index += 3;
continue;
}
}
out.push(if bytes[index] == b'+' {
b' '
} else {
bytes[index]
});
index += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn random_token(bytes: usize) -> Result<String, String> {
let mut data = vec![0u8; bytes];
File::open("/dev/urandom")
.and_then(|mut file| file.read_exact(&mut data))
.map_err(|error| format!("secure random: {error}"))?;
Ok(base64url(&data))
}
fn base64url(bytes: &[u8]) -> String {
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = ((chunk[0] as u32) << 16)
| ((chunk.get(1).copied().unwrap_or(0) as u32) << 8)
| chunk.get(2).copied().unwrap_or(0) as u32;
out.push(TABLE[((n >> 18) & 63) as usize] as char);
out.push(TABLE[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(TABLE[((n >> 6) & 63) as usize] as char);
}
if chunk.len() > 2 {
out.push(TABLE[(n & 63) as usize] as char);
}
}
out
}
fn base64url_decode(value: &str) -> Result<Vec<u8>, String> {
fn digit(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
let mut out = Vec::new();
for chunk in value.as_bytes().chunks(4) {
if chunk.len() < 2 {
return Err("invalid base64url".into());
}
let a = digit(chunk[0]).ok_or("invalid base64url")? as u32;
let b = digit(chunk[1]).ok_or("invalid base64url")? as u32;
let c = chunk.get(2).and_then(|value| digit(*value)).unwrap_or(0) as u32;
let d = chunk.get(3).and_then(|value| digit(*value)).unwrap_or(0) as u32;
let n = (a << 18) | (b << 12) | (c << 6) | d;
out.push((n >> 16) as u8);
if chunk.len() > 2 {
out.push((n >> 8) as u8);
}
if chunk.len() > 3 {
out.push(n as u8);
}
}
Ok(out)
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completed_callback_replays_without_reusing_authorization_code() {
let config = OidcConfig {
issuer: "https://issuer.invalid".into(),
internal_url: "http://127.0.0.1:1".into(),
client_id: "client".into(),
client_secret: "secret".into(),
redirect_uri: "https://tasks.invalid/auth/callback".into(),
allowed_emails: Vec::new(),
};
let mut pending = BTreeMap::from([(
"state".into(),
Pending {
verifier: "verifier".into(),
nonce: "nonce".into(),
expires_at: now() + 300,
completed: Some(Identity {
sub: "subject".into(),
email: "agent@example.com".into(),
}),
},
)]);
let replay = callback(&config, Some("state=state&code=already-used"), &mut pending)
.expect("completed callback should replay");
assert_eq!(replay.sub, "subject");
assert_eq!(replay.email, "agent@example.com");
}
}