generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 9s
Close the auth loop: clients obtain a bearer token and present it to the hub (tech-spec §13). - oauth module: DeviceFlow (RFC 8628 — discover, start, poll handling authorization_pending/slow_down, refresh) + StoredToken + TokenStore (OS keyring via `keyring`, in-memory for tests) + current_bearer (loads and refreshes-on-expiry). - heph auth login/logout: runs the device flow, prints the verification URL + user code, caches the token in the keyring. - sync_once gains a bearer arg; the daemon (Daemon::spawn_sync_loop + sync.now) obtains it via current_bearer; RemoteStore attaches it to /rpc. --oidc-issuer/--oidc-client-id configure the spoke/client. - Fix a latent panic: reqwest::blocking spins its own runtime and panics inside the daemon's spawn_blocking pool. All blocking auth/proxy HTTP (OidcVerifier JWKS, DeviceFlow, RemoteStore) now uses runtime-free `ureq`; async reqwest remains only for sync_once. (Caught by the new e2e test.) - Tests (offline): device flow + refresh + token store vs a mock OAuth server; a full spoke->authenticated-hub loop (valid token accepted, missing token rejected) signed by a runtime-generated RSA key. 112 tests green; clippy -D warnings + fmt + prek clean. Slice 10 (auth) complete; next is heph.nvim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
359 lines
12 KiB
Rust
359 lines
12 KiB
Rust
//! Hub authentication (tech-spec §13, slice 10a). Two layers, both offline:
|
|
//!
|
|
//! 1. **Middleware + owner gate** via a stub verifier — proves the hub rejects
|
|
//! missing/invalid tokens, admits valid ones, and enforces single-tenant
|
|
//! ownership — with zero crypto.
|
|
//! 2. **`OidcVerifier` against an in-process mock IdP** (a real RSA key + JWKS) —
|
|
//! an adversarial battery proving the *crypto* path accepts a good token and
|
|
//! rejects every common forgery (expired, wrong iss/aud, unknown kid,
|
|
//! tampered signature, `alg` confusion, missing `sub`).
|
|
//!
|
|
//! No external IdP is touched; Authentik is only needed for a manual smoke test.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{mpsc, Arc, Mutex, OnceLock};
|
|
use std::thread;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use axum::extract::State;
|
|
use axum::routing::get;
|
|
use axum::{Json, Router};
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use base64::Engine;
|
|
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
|
use rsa::pkcs8::{EncodePrivateKey, LineEnding};
|
|
use rsa::traits::PublicKeyParts;
|
|
use rsa::{RsaPrivateKey, RsaPublicKey};
|
|
use serde::Serialize;
|
|
use serde_json::{json, Value};
|
|
|
|
use heph_core::{FixedClock, LocalStore, NewNode, Store};
|
|
use hephd::auth::{AuthError, Claims, OidcVerifier, TokenVerifier};
|
|
use hephd::sync::{self, SharedStore};
|
|
|
|
const NOW: i64 = 1_704_067_200_000;
|
|
const AUDIENCE: &str = "heph-hub";
|
|
const KID: &str = "test-key-1";
|
|
|
|
/// A throwaway RSA keypair generated once per test run — no key material lives
|
|
/// in the repo. `pem` signs tokens; `n`/`e` (base64url) are served in the JWKS.
|
|
struct TestKey {
|
|
pem: String,
|
|
n: String,
|
|
e: String,
|
|
}
|
|
|
|
fn test_key() -> &'static TestKey {
|
|
static KEY: OnceLock<TestKey> = OnceLock::new();
|
|
KEY.get_or_init(|| {
|
|
let mut rng = rand::thread_rng();
|
|
let private = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA key");
|
|
let public = RsaPublicKey::from(&private);
|
|
TestKey {
|
|
pem: private
|
|
.to_pkcs8_pem(LineEnding::LF)
|
|
.expect("encode PEM")
|
|
.to_string(),
|
|
n: URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()),
|
|
e: URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()),
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- layer 1: middleware + owner gate (stub verifier) ---------------------
|
|
|
|
/// A verifier that maps known opaque tokens to subjects — no crypto.
|
|
struct StubVerifier(HashMap<String, String>);
|
|
impl TokenVerifier for StubVerifier {
|
|
fn verify(&self, bearer: &str) -> Result<Claims, AuthError> {
|
|
self.0
|
|
.get(bearer)
|
|
.map(|sub| Claims { sub: sub.clone() })
|
|
.ok_or_else(|| AuthError::Invalid("unknown stub token".into()))
|
|
}
|
|
}
|
|
|
|
/// Start a hub with the given verifier over a fresh temp store; return base URL.
|
|
fn start_hub(verifier: Option<Arc<dyn TokenVerifier>>) -> String {
|
|
let (tx, rx) = mpsc::channel();
|
|
thread::spawn(move || {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
rt.block_on(async move {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let store =
|
|
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
|
|
let shared: SharedStore = Arc::new(Mutex::new(store));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
tx.send(listener.local_addr().unwrap()).unwrap();
|
|
let _keep = dir;
|
|
axum::serve(listener, sync::router(shared, verifier))
|
|
.await
|
|
.unwrap();
|
|
});
|
|
});
|
|
format!(
|
|
"http://{}",
|
|
rx.recv_timeout(Duration::from_secs(5)).unwrap()
|
|
)
|
|
}
|
|
|
|
/// `POST /rpc health` with an optional bearer token; return the HTTP status.
|
|
fn rpc_health_status(base: &str, token: Option<&str>) -> u16 {
|
|
let mut req = ureq::post(format!("{base}/rpc"));
|
|
if let Some(t) = token {
|
|
req = req.header("Authorization", format!("Bearer {t}"));
|
|
}
|
|
match req.send_json(json!({ "method": "health", "params": {} })) {
|
|
Ok(resp) => resp.status().as_u16(),
|
|
Err(ureq::Error::StatusCode(code)) => code,
|
|
Err(e) => panic!("request failed: {e}"),
|
|
}
|
|
}
|
|
|
|
fn stub(pairs: &[(&str, &str)]) -> Option<Arc<dyn TokenVerifier>> {
|
|
let map = pairs
|
|
.iter()
|
|
.map(|(t, s)| (t.to_string(), s.to_string()))
|
|
.collect();
|
|
Some(Arc::new(StubVerifier(map)))
|
|
}
|
|
|
|
#[test]
|
|
fn open_hub_needs_no_token() {
|
|
let base = start_hub(None);
|
|
assert_eq!(rpc_health_status(&base, None), 200);
|
|
}
|
|
|
|
#[test]
|
|
fn authed_hub_rejects_missing_and_bad_tokens_admits_valid() {
|
|
let base = start_hub(stub(&[("tok-alice", "alice")]));
|
|
assert_eq!(rpc_health_status(&base, None), 401, "missing token");
|
|
assert_eq!(rpc_health_status(&base, Some("garbage")), 401, "bad token");
|
|
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200, "valid");
|
|
}
|
|
|
|
#[test]
|
|
fn owner_gate_rejects_a_second_identity() {
|
|
let base = start_hub(stub(&[("tok-alice", "alice"), ("tok-mallory", "mallory")]));
|
|
// Alice authenticates first and claims the hub.
|
|
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200);
|
|
// A different valid identity is forbidden (single-tenant isolation).
|
|
assert_eq!(rpc_health_status(&base, Some("tok-mallory")), 403);
|
|
// Alice still works.
|
|
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200);
|
|
}
|
|
|
|
// --- layer 2: OidcVerifier against a mock IdP (real RS256) -----------------
|
|
|
|
/// Start a mock OIDC provider (discovery + JWKS) on an ephemeral port; the
|
|
/// returned URL is both the issuer and the discovery base.
|
|
fn start_mock_idp() -> String {
|
|
let (tx, rx) = mpsc::channel();
|
|
thread::spawn(move || {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
rt.block_on(async move {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
tx.send(base.clone()).unwrap();
|
|
let app = Router::new()
|
|
.route("/.well-known/openid-configuration", get(discovery))
|
|
.route("/jwks", get(jwks))
|
|
.with_state(base);
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
});
|
|
rx.recv_timeout(Duration::from_secs(5)).unwrap()
|
|
}
|
|
|
|
async fn discovery(State(base): State<String>) -> Json<Value> {
|
|
Json(json!({ "issuer": base, "jwks_uri": format!("{base}/jwks") }))
|
|
}
|
|
|
|
async fn jwks() -> Json<Value> {
|
|
let key = test_key();
|
|
Json(json!({
|
|
"keys": [{
|
|
"kty": "RSA", "use": "sig", "alg": "RS256",
|
|
"kid": KID, "n": key.n, "e": key.e,
|
|
}]
|
|
}))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TokenClaims {
|
|
sub: String,
|
|
iss: String,
|
|
aud: String,
|
|
exp: u64,
|
|
iat: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct NoSubClaims {
|
|
iss: String,
|
|
aud: String,
|
|
exp: u64,
|
|
}
|
|
|
|
fn unix_now() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
}
|
|
|
|
fn rsa_key() -> EncodingKey {
|
|
EncodingKey::from_rsa_pem(test_key().pem.as_bytes()).unwrap()
|
|
}
|
|
|
|
/// Sign an RS256 token with the given `kid` and standard claims.
|
|
fn sign(claims: &TokenClaims, kid: &str) -> String {
|
|
let mut header = Header::new(Algorithm::RS256);
|
|
header.kid = Some(kid.to_string());
|
|
encode(&header, claims, &rsa_key()).unwrap()
|
|
}
|
|
|
|
fn good_claims(issuer: &str) -> TokenClaims {
|
|
TokenClaims {
|
|
sub: "hashed-eblume".into(),
|
|
iss: issuer.into(),
|
|
aud: AUDIENCE.into(),
|
|
exp: unix_now() + 3600,
|
|
iat: unix_now(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oidc_verifier_accepts_a_valid_token() {
|
|
let issuer = start_mock_idp();
|
|
let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE);
|
|
let token = sign(&good_claims(&issuer), KID);
|
|
let claims = verifier.verify(&token).expect("valid token accepted");
|
|
assert_eq!(claims.sub, "hashed-eblume");
|
|
}
|
|
|
|
#[test]
|
|
fn oidc_verifier_rejects_forgeries() {
|
|
let issuer = start_mock_idp();
|
|
let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE);
|
|
|
|
// expired (well past jsonwebtoken's default 60s leeway)
|
|
let mut c = good_claims(&issuer);
|
|
c.exp = unix_now() - 3600;
|
|
assert!(verifier.verify(&sign(&c, KID)).is_err(), "expired");
|
|
|
|
// wrong issuer
|
|
let mut c = good_claims(&issuer);
|
|
c.iss = "https://evil.example".into();
|
|
assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong iss");
|
|
|
|
// wrong audience
|
|
let mut c = good_claims(&issuer);
|
|
c.aud = "someone-else".into();
|
|
assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong aud");
|
|
|
|
// unknown signing key (kid not in JWKS, even after refetch)
|
|
assert!(
|
|
verifier
|
|
.verify(&sign(&good_claims(&issuer), "other-kid"))
|
|
.is_err(),
|
|
"unknown kid"
|
|
);
|
|
|
|
// tampered signature
|
|
let mut token = sign(&good_claims(&issuer), KID);
|
|
let last = token.pop().unwrap();
|
|
token.push(if last == 'A' { 'B' } else { 'A' });
|
|
assert!(verifier.verify(&token).is_err(), "tampered signature");
|
|
|
|
// algorithm confusion: HS256-signed token must be rejected (RS256 pinned)
|
|
let mut hs = Header::new(Algorithm::HS256);
|
|
hs.kid = Some(KID.to_string());
|
|
let hs_token = encode(&hs, &good_claims(&issuer), &EncodingKey::from_secret(b"x")).unwrap();
|
|
assert!(verifier.verify(&hs_token).is_err(), "HS256 confusion");
|
|
|
|
// alg: none — a header claiming no signature
|
|
let none_token = "eyJhbGciOiJub25lIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJzdWIiOiJ4In0.";
|
|
assert!(verifier.verify(none_token).is_err(), "alg none");
|
|
|
|
// missing sub
|
|
let nosub = NoSubClaims {
|
|
iss: issuer.clone(),
|
|
aud: AUDIENCE.into(),
|
|
exp: unix_now() + 3600,
|
|
};
|
|
let mut header = Header::new(Algorithm::RS256);
|
|
header.kid = Some(KID.to_string());
|
|
let token = encode(&header, &nosub, &rsa_key()).unwrap();
|
|
assert!(verifier.verify(&token).is_err(), "missing sub");
|
|
}
|
|
|
|
// --- layer 3: the loop closes — spoke ⇄ authed hub over real HTTP -----------
|
|
|
|
const OWNER: &str = "canonical-user";
|
|
|
|
/// Adopt the canonical owner over a temp store and share it.
|
|
fn shared_replica() -> SharedStore {
|
|
let dir = Box::leak(Box::new(tempfile::tempdir().unwrap()));
|
|
let mut store =
|
|
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
|
|
store.adopt_owner(OWNER).unwrap();
|
|
Arc::new(Mutex::new(store))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spoke_syncs_to_an_authed_hub_only_with_a_valid_token() {
|
|
let issuer = start_mock_idp();
|
|
|
|
// A hub that requires tokens issued by the mock IdP.
|
|
let hub_store = shared_replica();
|
|
let verifier = Arc::new(OidcVerifier::new(issuer.clone(), AUDIENCE));
|
|
let hub_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let hub_url = format!("http://{}", hub_listener.local_addr().unwrap());
|
|
{
|
|
let app = sync::router(hub_store.clone(), Some(verifier));
|
|
tokio::spawn(async move { axum::serve(hub_listener, app).await.unwrap() });
|
|
}
|
|
|
|
// A spoke with a local node to push.
|
|
let spoke = shared_replica();
|
|
let node_id = spoke
|
|
.lock()
|
|
.unwrap()
|
|
.create_node(NewNode::doc("Roof", "shingles"))
|
|
.unwrap()
|
|
.id;
|
|
|
|
let http = reqwest::Client::new();
|
|
|
|
// Without a token the hub refuses the exchange.
|
|
assert!(
|
|
sync::sync_once(spoke.clone(), &hub_url, &http, None)
|
|
.await
|
|
.is_err(),
|
|
"unauthenticated sync must fail"
|
|
);
|
|
|
|
// With a valid token signed by the IdP, the push is accepted and the node
|
|
// reaches the hub.
|
|
let token = sign(&good_claims(&issuer), KID);
|
|
let report = sync::sync_once(spoke.clone(), &hub_url, &http, Some(&token))
|
|
.await
|
|
.expect("authenticated sync succeeds");
|
|
assert!(report.pushed > 0, "spoke pushed nothing");
|
|
assert!(
|
|
hub_store
|
|
.lock()
|
|
.unwrap()
|
|
.get_node(&node_id)
|
|
.unwrap()
|
|
.is_some(),
|
|
"node did not reach the hub"
|
|
);
|
|
}
|