server/lib/tests/repository_tokens_for_package_clients.rs
//! ★ **Repository tokens for package clients** (lane P3): a person signed in with a passkey
//! mints a token for their repositories; cargo, pip, npm and maven carry it in their own
//! spelling; it never does more than its owner may; revoked, expired or orphaned it is
//! refused by its own code, and nothing else is said.
//!
//! A REAL holger (console bearer, passkey plane, two gated raw repositories), driven over
//! gRPC (`holger.token.v1.TokenService`) and HTTP with soft authenticators:
//!
//! * anna (admin) mints a read-and-write token for `private`; a PUT with it in cargo's
//! spelling (the bare header), maven's (Basic), npm's (Bearer) and pip's (`__token__`) all
//! land, and the token reads back what it wrote;
//! * the token is refused on `other` (`holger-token-4`), a read-only token is refused a write
//! (`holger-token-5`), and no token reaches an admin door or the mint door itself;
//! * rita (reader) cannot mint a write token (`holger-token-8`), the console's bearer mints
//! none (`holger-token-6`), a session that is not stepped up mints none (`holger-token-7`);
//! * listing shows anna her tokens with their last use and never a secret, rita only hers,
//! the console every token; the store holds hashes only; mint and revoke are audited;
//! * revoked (`holger-token-1`, with the challenge), expired (`holger-token-2`) and with its
//! owner's passkey revoked (`holger-token-3`), a token is refused; an unknown one gets the
//! plain 401.
use std::io::Write;
use traits::test_support::hold_port;
use holger_server_lib::grpc::holger_token_proto::token_service_client::TokenServiceClient;
use holger_server_lib::grpc::holger_token_proto::{
ListTokensRequest, MintTokenRequest, RevokeTokenRequest, TokenScope,
};
use holger_server_lib::{read_ron_config, wire_holger, Holger};
use nornir_auth_webauthn::softkey::SoftKey;
const RP_ID: &str = "holger.estate.test";
const ORIGIN: &str = "https://holger.estate.test";
const CONSOLE: &str = "c0nsole-bearer-0123456789abcdefghij";
struct Running {
data: tempfile::TempDir,
_cfg: tempfile::NamedTempFile,
_holger: Holger,
base: String,
grpc: String,
}
fn start() -> Running {
let mut last = String::new();
for _ in 0..8 {
let (grpc, http) = (hold_port(), hold_port());
match try_start(grpc, http) {
Ok(r) => return r,
Err(e) => last = e,
}
}
panic!("server never started; last error: {last}");
}
fn try_start(grpc: u16, http: u16) -> Result<Running, String> {
let data = tempfile::tempdir().unwrap();
let d = data.path().display().to_string();
let repo = |name: &str| {
format!(
r#"(ron_name: "{name}", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")),
ron_data_dir: Some("{d}/{name}"), ron_require_auth: true),"#
)
};
let ron = format!(
r#"(
repositories: [ {private} {other} ],
exposed_endpoints: [
(ron_name: "door", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
],
storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{d}") ],
audit: (dir: "{d}/audit", enabled: true),
auth: Some((
methods: [
StaticToken(subject: "console", token: Some("{CONSOLE}")),
Passkey(rp_id: "{RP_ID}", rp_origin: "{ORIGIN}"),
],
roles: {{"console": Admin}},
group_roles: {{"holger-admins": Admin, "holger-writers": Writer, "holger-readers": Reader}},
)),
)"#,
private = repo("private"),
other = repo("other"),
);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(ron.as_bytes()).unwrap();
let mut holger: Holger = read_ron_config(cfg.path()).map_err(|e| format!("config: {e}"))?;
holger
.instantiate_backends()
.map_err(|e| format!("backends: {e}"))?;
wire_holger(&mut holger).map_err(|e| format!("wire: {e}"))?;
holger.start().map_err(|e| format!("start: {e}"))?;
Ok(Running {
data,
_cfg: cfg,
_holger: holger,
base: format!("http://127.0.0.1:{http}"),
grpc: format!("http://127.0.0.1:{grpc}"),
})
}
async fn wait_healthy(c: &reqwest::Client, base: &str) {
for _ in 0..200 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status() == 200 {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("gateway never became healthy at {base}");
}
async fn post(
c: &reqwest::Client,
base: &str,
path: &str,
bearer: Option<&str>,
body: serde_json::Value,
) -> (u16, serde_json::Value) {
let mut req = c.post(format!("{base}{path}")).json(&body);
if let Some(b) = bearer {
req = req.bearer_auth(b);
}
let r = req.send().await.expect("the door answers");
let status = r.status().as_u16();
let text = r.text().await.unwrap_or_default();
(
status,
serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)),
)
}
async fn register(
c: &reqwest::Client,
base: &str,
key: &mut SoftKey,
bearer: Option<&str>,
body: serde_json::Value,
) -> (u16, serde_json::Value) {
let (status, start) = post(c, base, "/-/auth/passkey/register/start", bearer, body).await;
if status != 200 {
return (status, start);
}
let ceremony = start["ceremony_id"]
.as_str()
.expect("a ceremony id")
.to_owned();
let challenge = start["publicKey"]["challenge"]
.as_str()
.expect("a challenge");
let (client_data, attestation) = key.register(RP_ID, ORIGIN, challenge, true);
post(
c,
base,
"/-/auth/passkey/register/finish",
bearer,
serde_json::json!({
"ceremony_id": ceremony,
"id": key.credential_id_b64(),
"response": { "clientDataJSON": client_data, "attestationObject": attestation },
}),
)
.await
}
/// A login, user-verified or not: the session's token.
async fn login(c: &reqwest::Client, base: &str, key: &mut SoftKey, uv: bool) -> String {
let start_body = if uv {
serde_json::json!({ "uv": "required" })
} else {
serde_json::json!({})
};
let (status, start) = post(c, base, "/-/auth/passkey/login/start", None, start_body).await;
assert_eq!(status, 200, "{start}");
let ceremony = start["ceremony_id"]
.as_str()
.expect("a ceremony id")
.to_owned();
let challenge = start["publicKey"]["challenge"]
.as_str()
.expect("a challenge");
let (client_data, auth_data, signature) = key.authenticate(RP_ID, ORIGIN, challenge, uv);
let (status, out) = post(
c,
base,
"/-/auth/passkey/login/finish",
None,
serde_json::json!({
"ceremony_id": ceremony,
"id": key.credential_id_b64(),
"response": { "clientDataJSON": client_data, "authenticatorData": auth_data, "signature": signature, "userHandle": null },
}),
)
.await;
assert_eq!(status, 200, "logs in: {out}");
out["token"].as_str().expect("a token").to_owned()
}
fn authed<T>(msg: T, bearer: &str) -> tonic::Request<T> {
let mut r = tonic::Request::new(msg);
r.metadata_mut()
.insert("authorization", format!("Bearer {bearer}").parse().unwrap());
r
}
async fn tokens(grpc: &str) -> TokenServiceClient<tonic::transport::Channel> {
for _ in 0..200 {
if let Ok(c) = TokenServiceClient::connect(grpc.to_string()).await {
return c;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("gRPC never came up at {grpc}");
}
fn mint(label: &str, repos: &[&str], scope: TokenScope, days: u32) -> MintTokenRequest {
MintTokenRequest {
label: label.into(),
repositories: repos.iter().map(|s| s.to_string()).collect(),
scope: scope as i32,
days,
}
}
fn basic(user: &str, pass: &str) -> String {
use base64::Engine as _;
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"))
)
}
/// One request to the repository door with `authorization` as given: status and body.
async fn door(
c: &reqwest::Client,
method: reqwest::Method,
url: &str,
authorization: Option<&str>,
body: &'static [u8],
) -> (u16, String, Option<String>) {
let mut req = c.request(method, url).body(body);
if let Some(a) = authorization {
req = req.header("authorization", a);
}
let r = req.send().await.expect("the door answers");
let status = r.status().as_u16();
let challenge = r
.headers()
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
(status, r.text().await.unwrap_or_default(), challenge)
}
#[tokio::test(flavor = "multi_thread")]
async fn a_person_mints_a_repository_token_and_every_package_client_spelling_carries_it() {
let r = start();
let c = reqwest::Client::builder().build().expect("reqwest client");
wait_healthy(&c, &r.base).await;
let base = r.base.as_str();
let mut ts = tokens(&r.grpc).await;
// ── the people: anna the admin (enrolled by the console), rita a reader (invited) ──
let mut anna_key = SoftKey::new(0x41, b"cred-anna-0001");
let (status, reg) = register(
&c,
base,
&mut anna_key,
Some(CONSOLE),
serde_json::json!({ "display": "anna", "role": "admin" }),
)
.await;
assert_eq!(status, 200, "the console enrols the first admin: {reg}");
let anna = login(&c, base, &mut anna_key, true).await;
let (status, minted) = post(
&c,
base,
"/-/auth/passkey/invites",
Some(&anna),
serde_json::json!({ "display": "rita", "role": "reader" }),
)
.await;
assert_eq!(status, 200, "{minted}");
let mut rita_key = SoftKey::new(0x52, b"cred-rita-0001");
let (status, reg) = register(
&c,
base,
&mut rita_key,
None,
serde_json::json!({ "display": "rita", "invite": minted["invite"].as_str().unwrap() }),
)
.await;
assert_eq!(status, 200, "{reg}");
let rita = login(&c, base, &mut rita_key, true).await;
// ── anna mints a read-and-write token for `private` ──
let m = ts
.mint(authed(
mint("laptop", &["private"], TokenScope::ReadWrite, 30),
&anna,
))
.await
.expect("anna mints")
.into_inner();
let rw = m.secret.clone();
assert!(
rw.starts_with("holger_rt_"),
"the prefixed secret, shown once"
);
let row = m.token.expect("the stored row");
assert_eq!(
(
row.repositories.as_slice(),
row.state.as_str(),
row.owner_display.as_str()
),
(&["private".to_string()][..], "live", "anna")
);
// ── every client spelling: cargo's bare header, maven's Basic, npm's Bearer, pip's __token__ ──
let put = |name: &str| format!("{base}/private/{name}");
for (client, authz) in [
("cargo", rw.clone()),
("maven", basic("anna", &rw)),
("npm", format!("Bearer {rw}")),
("pip", basic("__token__", &rw)),
] {
let (status, body, _) = door(
&c,
reqwest::Method::PUT,
&put(&format!("{client}.txt")),
Some(&authz),
b"made by a token",
)
.await;
assert_eq!(status, 201, "{client}'s spelling writes: {body}");
let (status, body, _) = door(
&c,
reqwest::Method::GET,
&put(&format!("{client}.txt")),
Some(&authz),
b"",
)
.await;
assert_eq!(
(status, body.as_str()),
(200, "made by a token"),
"{client}'s spelling reads back"
);
}
let (status, _, _) = door(&c, reqwest::Method::GET, &put("cargo.txt"), None, b"").await;
assert_eq!(status, 401, "the repository is gated");
// ── held to its repositories and its scope ──
let (status, body, _) = door(
&c,
reqwest::Method::GET,
&format!("{base}/other/x"),
Some(&format!("Bearer {rw}")),
b"",
)
.await;
assert_eq!(status, 403, "{body}");
assert!(
body.starts_with("[holger-token-4]"),
"not for this repository: {body}"
);
assert!(
!body.contains("private"),
"the refusal does not say what the token is for: {body}"
);
let ro = ts
.mint(authed(
mint("read only", &["private"], TokenScope::Read, 0),
&anna,
))
.await
.unwrap()
.into_inner()
.secret;
let (status, body, _) = door(
&c,
reqwest::Method::PUT,
&put("ro.txt"),
Some(&format!("Bearer {ro}")),
b"x",
)
.await;
assert_eq!(status, 403, "{body}");
assert!(
body.starts_with("[holger-token-5]"),
"a read-only token never writes: {body}"
);
let (status, _, _) = door(
&c,
reqwest::Method::GET,
&put("cargo.txt"),
Some(&format!("Bearer {ro}")),
b"",
)
.await;
assert_eq!(status, 200, "and reads");
// ── a token reaches no other door: not the passkey door, not an admin door, not the mint ──
let (status, _) = post(
&c,
base,
"/-/auth/passkey/invites",
Some(&rw),
serde_json::json!({ "role": "reader" }),
)
.await;
assert_eq!(status, 401, "a token mints no invite");
let refused = ts
.mint(authed(
mint("again", &["private"], TokenScope::Read, 0),
&rw,
))
.await
.unwrap_err();
assert_eq!(
refused.code(),
tonic::Code::Unauthenticated,
"a token mints no token: {refused}"
);
let refused = ts
.list(authed(ListTokensRequest {}, &rw))
.await
.unwrap_err();
assert_eq!(
refused.code(),
tonic::Code::Unauthenticated,
"a token lists nothing: {refused}"
);
// ── never more than the owner: rita reads, so rita mints no write token ──
let refused = ts
.mint(authed(
mint("ci", &["private"], TokenScope::ReadWrite, 0),
&rita,
))
.await
.unwrap_err();
assert_eq!(refused.code(), tonic::Code::PermissionDenied, "{refused}");
assert!(
refused.message().starts_with("[holger-token-8]"),
"{}",
refused.message()
);
let refused = ts
.mint(authed(
mint("ci", &["no-such-repo"], TokenScope::Read, 0),
&rita,
))
.await
.unwrap_err();
assert!(
refused.message().starts_with("[holger-token-8]"),
"an unknown repository is the same refusal: {}",
refused.message()
);
let rita_ro = ts
.mint(authed(
mint("rita reads", &["private"], TokenScope::Read, 0),
&rita,
))
.await
.expect("a read token is hers")
.into_inner()
.secret;
let (status, _, _) = door(
&c,
reqwest::Method::GET,
&put("cargo.txt"),
Some(&basic("__token__", &rita_ro)),
b"",
)
.await;
assert_eq!(status, 200);
// ── only a person mints, and only stepped up ──
let refused = ts
.mint(authed(
mint("box", &["private"], TokenScope::Read, 0),
CONSOLE,
))
.await
.unwrap_err();
assert!(
refused.message().starts_with("[holger-token-6]"),
"the console's bearer is no person: {}",
refused.message()
);
let lazy = login(&c, base, &mut anna_key, false).await;
let refused = ts
.mint(authed(
mint("lazy", &["private"], TokenScope::Read, 0),
&lazy,
))
.await
.unwrap_err();
assert!(
refused.message().starts_with("[holger-token-7]"),
"a mint needs a user-verified login: {}",
refused.message()
);
let refused = ts
.mint(authed(
mint("forever", &["private"], TokenScope::Read, 367),
&anna,
))
.await
.unwrap_err();
assert!(
refused.message().starts_with("[holger-token-9]"),
"{}",
refused.message()
);
// ── listing: hers, theirs, everyone's; a last use; never a secret ──
let listed = ts
.list(authed(ListTokensRequest {}, &anna))
.await
.unwrap()
.into_inner()
.tokens;
assert_eq!(
listed.len(),
3,
"anna is an admin of this server: every token"
);
let mine: Vec<_> = listed
.into_iter()
.filter(|t| t.owner_display == "anna")
.collect();
assert_eq!(mine.len(), 2, "anna's two");
let used = mine.iter().find(|t| t.label == "laptop").expect("listed");
assert!(used.last_used > 0, "its last use is listed");
let hers = ts
.list(authed(ListTokensRequest {}, &rita))
.await
.unwrap()
.into_inner()
.tokens;
assert_eq!(
hers.iter().map(|t| t.label.as_str()).collect::<Vec<_>>(),
["rita reads"],
"rita sees only hers"
);
let all = ts
.list(authed(ListTokensRequest {}, CONSOLE))
.await
.unwrap()
.into_inner()
.tokens;
assert_eq!(all.len(), 3, "an admin sees every token");
let store =
std::fs::read_to_string(r.data.path().join("holger-passkeys/repository-tokens.json"))
.expect("the store beside the passkeys");
for secret in [&rw, &ro, &rita_ro] {
assert!(
!store.contains(secret.as_str()) && !store.contains(&secret["holger_rt_".len()..]),
"hashes only"
);
}
// ── revoke: not rita's to revoke; anna's own; then refused with the challenge ──
let refused = ts
.revoke(authed(
RevokeTokenRequest {
id: used.id.clone(),
},
&rita,
))
.await
.unwrap_err();
assert_eq!(
refused.code(),
tonic::Code::NotFound,
"another person's token is not there for her: {refused}"
);
assert!(refused.message().starts_with("[holger-token-10]"));
let gone = ts
.revoke(authed(
RevokeTokenRequest {
id: used.id.clone(),
},
&anna,
))
.await
.unwrap()
.into_inner()
.token
.unwrap();
assert_eq!(gone.state, "revoked");
let (status, body, challenge) =
door(&c, reqwest::Method::GET, &put("cargo.txt"), Some(&rw), b"").await;
assert_eq!(status, 401, "{body}");
assert!(
body.starts_with("[holger-token-1]"),
"revoked, by its own code: {body}"
);
assert!(
challenge.is_some_and(|h| h.starts_with("Basic")),
"with the challenge a client acts on"
);
let (status, body, _) = door(
&c,
reqwest::Method::PUT,
&put("after.txt"),
Some(&basic("__token__", &rw)),
b"x",
)
.await;
assert_eq!(status, 401);
assert!(body.starts_with("[holger-token-1]"), "{body}");
let listed = ts
.list(authed(ListTokensRequest {}, &anna))
.await
.unwrap()
.into_inner()
.tokens;
assert_eq!(
listed
.iter()
.find(|t| t.id == used.id)
.map(|t| t.state.as_str()),
Some("revoked"),
"kept and listed"
);
// ── expired: the store says it ended yesterday ──
let path = r.data.path().join("holger-passkeys/repository-tokens.json");
let mut rows: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for t in rows.as_array_mut().unwrap() {
if t["label"] == "read only" {
t["expires"] = serde_json::json!(1);
}
}
std::fs::write(&path, serde_json::to_vec(&rows).unwrap()).unwrap();
let (status, body, _) = door(
&c,
reqwest::Method::GET,
&put("cargo.txt"),
Some(&format!("Bearer {ro}")),
b"",
)
.await;
assert_eq!(status, 401, "{body}");
assert!(
body.starts_with("[holger-token-2]"),
"expired, by its own code: {body}"
);
// ── the owner's passkey revoked: her token is worth nothing ──
let (status, creds) = post(
&c,
base,
"/-/auth/passkey/credentials/list",
Some(CONSOLE),
serde_json::json!({}),
)
.await;
assert_eq!(status, 200, "{creds}");
let rita_cred = creds["credentials"]
.as_array()
.unwrap()
.iter()
.find(|c| c["display"] == "rita")
.expect("rita's key")["id"]
.as_str()
.unwrap()
.to_owned();
let (status, out) = post(
&c,
base,
"/-/auth/passkey/credentials/revoke",
Some(CONSOLE),
serde_json::json!({ "id": rita_cred }),
)
.await;
assert_eq!(status, 200, "{out}");
let (status, body, _) = door(
&c,
reqwest::Method::GET,
&put("cargo.txt"),
Some(&basic("__token__", &rita_ro)),
b"",
)
.await;
assert_eq!(status, 401, "{body}");
assert!(
body.starts_with("[holger-token-3]"),
"an orphaned token: {body}"
);
// ── an unknown token is the plain refusal a forged credential gets ──
let (status, body, _) = door(
&c,
reqwest::Method::GET,
&put("cargo.txt"),
Some("Bearer holger_rt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
b"",
)
.await;
assert_eq!((status, body.as_str()), (401, "Unauthorized"));
// ── mint and revoke are in the audit ──
let mut audit = String::new();
for f in std::fs::read_dir(r.data.path().join("audit"))
.unwrap()
.flatten()
{
audit.push_str(&std::fs::read_to_string(f.path()).unwrap_or_default());
}
assert!(
audit.contains("repository token minted"),
"the mint is audited"
);
assert!(
audit.contains("repository token revoked"),
"the revoke is audited"
);
assert!(!audit.contains(&rw), "never a secret in the audit");
}