Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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");
}