Menu
Public repository / 1b6130c5a11fd67aea78991820e14225c714dafc
AkurAI-VPN / crates/akurai-control/src/auth.rs
Latest change 1b6130c5a11fd67aea78991820e14225c714dafc - feat: add AkurAI IDP OIDC login, session management, and VPN endpoint CRUD (v0.0.3) by Claude Code
//! OIDC Authorization Code Flow helpers — std-only, zero external crates.
//!
//! **BLOCKER — JWT signature not verified.** The AkurAI IDP uses EdDSA
//! (Ed25519). Verifying the signature requires the `ed25519-dalek` crate,
//! which is blocked on the UNRESOLVED DECISION in Cargo.toml. The
//! code→token exchange is authenticated by the IDP so the claim content is
//! trustworthy for an MVP, but production deployments must add signature
//! verification once the crypto-crate decision is resolved.
//!
//! **Required env vars** (server logs a warning and disables login if any
//! are absent):
//! `OIDC_CLIENT_ID` — client ID registered in the AkurAI IDP
//! `OIDC_CLIENT_SECRET` — client secret for the token endpoint
//! `OIDC_REDIRECT_URI` — callback URL, e.g. `https://vpn.olibuijr.com/auth/callback`
//! `OIDC_ISSUER_URL` — IDP base URL (default: `https://auth.olibuijr.com`)
use std::collections::HashMap;
use std::io::Read;
// ---------------------------------------------------------------------------
// AuthUser & session store
// ---------------------------------------------------------------------------
/// Authenticated principal decoded from the OIDC id_token payload.
#[derive(Debug, Clone)]
pub struct AuthUser {
/// OIDC subject identifier (opaque, stable per user).
pub sub: String,
pub email: String,
/// Display name from the `name` or `preferred_username` claim.
pub name: String,
}
/// In-memory session store.
#[derive(Debug, Default)]
pub struct SessionStore {
sessions: HashMap<String, AuthUser>,
}
impl SessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, token: String, user: AuthUser) {
self.sessions.insert(token, user);
}
pub fn get(&self, token: &str) -> Option<&AuthUser> {
self.sessions.get(token)
}
pub fn remove(&mut self, token: &str) {
self.sessions.remove(token);
}
}
// ---------------------------------------------------------------------------
// OIDC config
// ---------------------------------------------------------------------------
/// Configuration loaded from environment variables.
pub struct OidcConfig {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub issuer_url: String,
}
impl OidcConfig {
/// Returns `None` if any required env var is missing.
pub fn from_env() -> Option<Self> {
Some(Self {
client_id: std::env::var("OIDC_CLIENT_ID").ok()?,
client_secret: std::env::var("OIDC_CLIENT_SECRET").ok()?,
redirect_uri: std::env::var("OIDC_REDIRECT_URI").ok()?,
issuer_url: std::env::var("OIDC_ISSUER_URL")
.unwrap_or_else(|_| "https://auth.olibuijr.com".to_string()),
})
}
}
// ---------------------------------------------------------------------------
// OIDC flow helpers
// ---------------------------------------------------------------------------
/// Build the redirect URL for the OIDC authorization endpoint.
pub fn build_authorize_url(config: &OidcConfig, state: &str) -> String {
format!(
"{}/authorize?response_type=code&client_id={}&redirect_uri={}&scope=openid+email+profile&state={}",
config.issuer_url,
url_encode(&config.client_id),
url_encode(&config.redirect_uri),
url_encode(state),
)
}
/// Exchange an authorization code for tokens by spawning `curl`.
///
/// **Runtime requirement**: `curl` must be on PATH. The IDP token endpoint is
/// HTTPS; a std-only TLS client is not possible without a crypto crate (see
/// the UNRESOLVED DECISION in `Cargo.toml`). `curl` on the server host is the
/// pragmatic workaround for the MVP.
pub fn exchange_code(config: &OidcConfig, code: &str) -> Result<String, String> {
let token_url = format!("{}/token", config.issuer_url);
let body = format!(
"grant_type=authorization_code&code={code}&redirect_uri={redirect}&client_id={id}&client_secret={secret}",
code = url_encode(code),
redirect = url_encode(&config.redirect_uri),
id = url_encode(&config.client_id),
secret = url_encode(&config.client_secret),
);
let output = std::process::Command::new("curl")
.args([
"-s",
"--max-time",
"10",
"-X",
"POST",
"-H",
"Content-Type: application/x-www-form-urlencoded",
"-d",
&body,
&token_url,
])
.output()
.map_err(|e| format!("curl exec failed: {e}"))?;
if !output.status.success() {
return Err(format!(
"curl exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
));
}
String::from_utf8(output.stdout).map_err(|e| format!("curl output not UTF-8: {e}"))
}
/// Extract the `id_token` string from a token endpoint JSON response body.
pub fn extract_id_token(json: &str) -> Option<String> {
extract_json_str(json, "id_token")
}
/// Decode the JWT payload and return user identity fields.
///
/// **Signature is NOT verified** — blocked on the crypto crate decision.
/// The code→token exchange authenticates the response so this is safe for
/// the MVP. See module-level docs.
pub fn decode_jwt_claims(id_token: &str) -> Option<AuthUser> {
let payload_b64 = id_token.split('.').nth(1)?;
let bytes = base64url_decode(payload_b64)?;
let payload = String::from_utf8(bytes).ok()?;
Some(AuthUser {
sub: extract_json_str(&payload, "sub").unwrap_or_default(),
email: extract_json_str(&payload, "email").unwrap_or_default(),
name: extract_json_str(&payload, "name")
.or_else(|| extract_json_str(&payload, "preferred_username"))
.unwrap_or_default(),
})
}
// ---------------------------------------------------------------------------
// Session / cookie helpers
// ---------------------------------------------------------------------------
/// Generate a random 64-hex-char session token from `/dev/urandom`.
pub fn random_token() -> String {
let mut buf = [0u8; 32];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
let _ = f.read_exact(&mut buf);
}
buf.iter().map(|b| format!("{b:02x}")).collect()
}
/// Extract the `akurai_session` value from a raw Cookie header string.
pub fn parse_session_cookie(cookie_header: &str) -> Option<String> {
for part in cookie_header.split(';') {
if let Some(val) = part.trim().strip_prefix("akurai_session=") {
return Some(val.trim().to_string());
}
}
None
}
// ---------------------------------------------------------------------------
// URL encode / decode
// ---------------------------------------------------------------------------
/// Percent-encode a string for inclusion in a URL query parameter value.
pub fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Percent-decode a query value or form field.
pub fn url_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
out.push(char::from(
from_hex(bytes[i + 1]) << 4 | from_hex(bytes[i + 2]),
));
i += 3;
} else if bytes[i] == b'+' {
out.push(' ');
i += 1;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn from_hex(b: u8) -> u8 {
match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => 0,
}
}
/// Parse an `application/x-www-form-urlencoded` body.
pub fn parse_form(body: &str) -> HashMap<String, String> {
body.split('&')
.filter_map(|pair| pair.split_once('='))
.map(|(k, v)| (url_decode(k), url_decode(v)))
.collect()
}
// ---------------------------------------------------------------------------
// Minimal JSON extraction (no serde — zero crate dep)
// ---------------------------------------------------------------------------
/// Extract a JSON string value by key from a flat JSON object.
///
/// Handles simple cases only: does not parse deep nesting, but correctly
/// handles `\"` escapes inside values.
pub fn extract_json_str(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let start = json.find(&needle)?;
let rest = json[start + needle.len()..].trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let inner = rest.strip_prefix('"')?;
let end = json_str_end(inner)?;
Some(inner[..end].replace("\\\"", "\"").replace("\\\\", "\\"))
}
fn json_str_end(s: &str) -> Option<usize> {
let mut chars = s.char_indices();
while let Some((i, c)) = chars.next() {
if c == '\\' {
chars.next();
} else if c == '"' {
return Some(i);
}
}
None
}
/// Extract a JSON string array by key from a flat JSON object.
///
/// Items must not contain commas (fine for IP prefixes, keys, names).
pub fn extract_json_str_array(json: &str, key: &str) -> Vec<String> {
let needle = format!("\"{key}\"");
let Some(start) = json.find(&needle) else {
return Vec::new();
};
let rest = json[start + needle.len()..].trim_start();
let Some(rest) = rest.strip_prefix(':') else {
return Vec::new();
};
let rest = rest.trim_start();
if !rest.starts_with('[') {
return Vec::new();
}
let end = rest.find(']').unwrap_or(rest.len());
rest[1..end]
.split(',')
.map(str::trim)
.filter_map(|s| s.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
.map(str::to_string)
.collect()
}
// ---------------------------------------------------------------------------
// Base64url decode (for JWT payload — no external crate)
// ---------------------------------------------------------------------------
fn base64url_decode(s: &str) -> Option<Vec<u8>> {
let standard = s.replace('-', "+").replace('_', "/");
let rem = standard.len() % 4;
let padded = if rem == 0 {
standard
} else {
format!("{standard}{}", "=".repeat(4 - rem))
};
base64_decode(&padded)
}
const B64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let chars: Vec<u8> = s
.bytes()
.filter(|&b| b != b'=')
.map(|b| B64_TABLE.iter().position(|&c| c == b).map(|p| p as u8))
.collect::<Option<Vec<_>>>()?;
let mut out = Vec::with_capacity(chars.len() * 3 / 4);
for chunk in chars.chunks(4) {
match *chunk {
[a, b, c, d] => {
out.push((a << 2) | (b >> 4));
out.push((b << 4) | (c >> 2));
out.push((c << 6) | d);
}
[a, b, c] => {
out.push((a << 2) | (b >> 4));
out.push((b << 4) | (c >> 2));
}
[a, b] => {
out.push((a << 2) | (b >> 4));
}
_ => {}
}
}
Some(out)
}