use cairn_core::{
drain_resp_headers, lower, web, Confidence, Effect, ExprSpec, FunctionSpec,
ModuleSpec, Param, Produces, StepSpec, Type,
};
use cairn_core::{serve_request_db, serve_request_db_h, Editor, Store};
use std::collections::BTreeSet;
fn r(n: &str) -> ExprSpec {
ExprSpec::Ref(n.into())
}
fn lit(s: &str) -> ExprSpec {
ExprSpec::Str(s.into())
}
fn num(n: i64) -> ExprSpec {
ExprSpec::Lit(n)
}
fn cat(a: ExprSpec, b: ExprSpec) -> ExprSpec {
ExprSpec::StrConcat(Box::new(a), Box::new(b))
}
fn cats(parts: Vec<ExprSpec>) -> ExprSpec {
let mut it = parts.into_iter();
let f = it.next().unwrap();
it.fold(f, cat)
}
fn n2s(e: ExprSpec) -> ExprSpec {
ExprSpec::NumberToStr(Box::new(e))
}
fn streq(a: ExprSpec, b: ExprSpec) -> ExprSpec {
ExprSpec::StrEq(Box::new(a), Box::new(b))
}
fn field(b: ExprSpec, t: &str, f: &str) -> ExprSpec {
ExprSpec::Field {
base: Box::new(b),
type_name: t.into(),
field: f.into(),
}
}
fn call(f: &str, a: Vec<ExprSpec>) -> ExprSpec {
ExprSpec::Call {
func: f.into(),
args: a,
}
}
fn iff(c: ExprSpec, t: ExprSpec, e: ExprSpec) -> ExprSpec {
ExprSpec::If {
cond: Box::new(c),
then_branch: Box::new(t),
else_branch: Box::new(e),
}
}
fn dbq(sql: &str, params: Vec<ExprSpec>) -> ExprSpec {
ExprSpec::DbQuery {
sql: Box::new(lit(sql)),
params: Box::new(if params.is_empty() {
ExprSpec::ListEmpty { elem: Type::String }
} else {
ExprSpec::List(params)
}),
}
}
fn set_header(name: &str, value: ExprSpec) -> ExprSpec {
ExprSpec::SetHeader {
name: Box::new(lit(name)),
value: Box::new(value),
}
}
fn resp(status: i64, body: ExprSpec) -> ExprSpec {
ExprSpec::Record {
type_name: "Response".into(),
fields: vec![
("status".into(), num(status)),
("body".into(), body),
],
}
}
fn pr(name: &str) -> Param {
Param {
name: name.into(),
ty: Type::Named("Request".into()),
min_confidence: Confidence::External,
}
}
fn ext(t: Type) -> Produces {
Produces {
ty: t,
confidence: Confidence::External,
}
}
fn step(b: &str, v: ExprSpec) -> StepSpec {
StepSpec {
binding: b.into(),
value: v,
}
}
fn effs(es: &[Effect]) -> BTreeSet<Effect> {
es.iter().copied().collect()
}
const CREATE: &str = "CREATE TABLE IF NOT EXISTS sessions(tok TEXT)";
const ADMIN: &str = "s3cret";
fn auth_module() -> ModuleSpec {
let login = FunctionSpec {
name: "login".into(),
type_params: vec![],
params: vec![pr("req")],
produces: ext(Type::Named("Response".into())),
requires: effs(&[Effect::Db, Effect::Rand, Effect::Resp]),
on_failure: vec![],
steps: vec![
step("c", dbq(CREATE, vec![])),
step(
"sec",
call(
"form_value",
vec![field(r("req"), "Request", "body"), lit("secret")],
),
),
step("tok", n2s(ExprSpec::Rand)),
],
result: iff(
streq(r("sec"), lit(ADMIN)),
resp(
200,
cats(vec![
lit("in:"),
dbq(
"INSERT INTO sessions(tok) VALUES(?)",
vec![r("tok")],
),
n2s(set_header(
"Set-Cookie",
cats(vec![
lit("sid="),
r("tok"),
lit("; HttpOnly; Path=/"),
]),
)),
]),
),
resp(200, lit("no")),
),
};
let require_admin = FunctionSpec {
name: "require_admin".into(),
type_params: vec![],
params: vec![pr("req")],
produces: ext(Type::String),
requires: effs(&[Effect::Db]),
on_failure: vec![],
steps: vec![
step("c", dbq(CREATE, vec![])),
step("sid", call("cookie", vec![r("req"), lit("sid")])),
step(
"n",
dbq(
"SELECT count(*) FROM sessions WHERE tok=?",
vec![r("sid")],
),
),
],
result: iff(
streq(r("sid"), lit("")),
lit("deny"),
iff(streq(r("n"), lit("1")), lit("ok"), lit("deny")),
),
};
let admin_only = FunctionSpec {
name: "admin_only".into(),
type_params: vec![],
params: vec![pr("req")],
produces: ext(Type::Named("Response".into())),
requires: effs(&[Effect::Db]),
on_failure: vec![],
steps: vec![step("g", call("require_admin", vec![r("req")]))],
result: iff(
streq(r("g"), lit("ok")),
resp(200, lit("secret-area")),
resp(403, lit("forbidden")),
),
};
let logout = FunctionSpec {
name: "logout".into(),
type_params: vec![],
params: vec![pr("req")],
produces: ext(Type::Named("Response".into())),
requires: effs(&[Effect::Resp]),
on_failure: vec![],
steps: vec![],
result: resp(
200,
cat(
lit("out"),
n2s(set_header(
"Set-Cookie",
lit("sid=; Path=/; Max-Age=0"),
)),
),
),
};
ModuleSpec {
name: "auth".into(),
types: web::types(),
functions: {
let mut f = web::functions();
f.extend([login, require_admin, admin_only, logout]);
f
},
}
}
#[test]
fn the_session_auth_guard_works_end_to_end_over_real_sqlite() {
let editor = Editor::new(Store::open_in_memory().unwrap());
let (module, report) = editor.apply_module(&auth_module()).unwrap();
assert!(
report.ok(),
"auth did not type-check: {:?}",
report.violations
);
let wasm = lower(editor.store(), &module).unwrap();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dbp = std::env::temp_dir()
.join(format!("cairn_auth_{}_{nanos}.db", std::process::id()));
let db = dbp.to_str().unwrap().to_string();
let _ = std::fs::remove_file(&dbp);
let _ = drain_resp_headers();
let r0 =
serve_request_db(&wasm, "admin_only", &db, "GET", "/admin", "")
.unwrap();
assert_eq!(r0.status, 403);
assert_eq!(r0.body, "forbidden");
assert!(drain_resp_headers().is_empty());
let bad = serve_request_db(
&wasm, "login", &db, "POST", "/login", "secret=nope",
)
.unwrap();
assert_eq!(bad.body, "no");
assert!(
drain_resp_headers().is_empty(),
"a failed login must not set a cookie"
);
let ok = serve_request_db(
&wasm,
"login",
&db,
"POST",
"/login",
&format!("secret={ADMIN}"),
)
.unwrap();
assert!(ok.body.starts_with("in:"), "login body: {}", ok.body);
let hs = drain_resp_headers();
let (hn, hv) = hs
.iter()
.find(|(n, _)| n == "Set-Cookie")
.expect("login emits Set-Cookie");
assert_eq!(hn, "Set-Cookie");
assert!(
hv.starts_with("sid=") && hv.ends_with("; HttpOnly; Path=/"),
"cookie attrs: {hv}"
);
let tok = hv["sid=".len()..hv.len() - "; HttpOnly; Path=/".len()]
.to_string();
assert!(!tok.is_empty(), "an opaque token was issued");
let authed = serve_request_db_h(
&wasm,
"admin_only",
&db,
"GET",
"/admin",
"",
&format!("Cookie: sid={tok}"),
)
.unwrap();
assert_eq!(authed.status, 200, "valid session must pass the guard");
assert_eq!(authed.body, "secret-area");
let _ = drain_resp_headers();
let out =
serve_request_db(&wasm, "logout", &db, "POST", "/logout", "")
.unwrap();
assert!(out.body.starts_with("out"), "logout body: {}", out.body);
let cleared = drain_resp_headers();
assert!(
cleared
.iter()
.any(|(n, v)| n == "Set-Cookie" && v.contains("Max-Age=0")),
"logout clears the cookie: {cleared:?}"
);
for spoof in ["Cookie: sid=", "Cookie: sid=deadbeefnotreal"] {
let denied = serve_request_db_h(
&wasm, "admin_only", &db, "GET", "/admin", "", spoof,
)
.unwrap();
assert_eq!(denied.status, 403, "spoofed cookie {spoof:?}");
let _ = drain_resp_headers();
}
let _ = std::fs::remove_file(&dbp);
}