crates/gunnar-grpc/tests/web_login.rs
//! **A web login is the identity core's, over Gunnar's people** (IDENTITY-HOST,
//! 2026-10-27; Stage L before it, 2026-10-04: "passkeys + invites +
//! passkey↔principal pin → gunnar-server").
//!
//! Gunnar's own `Access.WebChallenge/WebLogin/WebRegister` are gone: the
//! console relays `nornir.identity.v1.IdentityService` (LoginStart/Finish,
//! RedeemStart/Finish) and the core verifies the ceremony. What stays Gunnar's
//! is WHERE the passkey and the person live — the control plane's principals
//! and web credentials — and what a session then is: a credential the real gate
//! takes as the person (`nornir-identity-session`). These tests hold that
//! half, through the core over Gunnar's plane:
//!
//! * a passkey Gunnar holds logs its person in, and its counter and last use move;
//! * a broken or foreign assertion mints nothing;
//! * an invite's groups are Gunnar's server grant, applied once;
//! * a registration whose attestation names another key stores nothing;
//! * one browser's flood of logins leaves everybody else's alone;
//! * the server decides user verification.
//!
//! The ceremony's own checks (each field of the client data, the counter) are
//! the core's conformance suite, run against Gunnar in `identity_conformance`.
use std::sync::Arc;
use gunnar_grpc::identity::{
system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, SERVER_ADMIN,
SERVER_WRITE,
};
use gunnar_grpc::Identity as _;
use gunnar_grpc::{ControlPlane, MemoryControlPlane};
use gunnar_proto::*;
use nornir_identity::conformance::{config, first_admin_stepped, relay, Harness, ORIGIN};
use nornir_identity::test_host::{Kit, SoftPasskey};
use nornir_identity::{
ClientKey, Code, Config, Identity, InviteAsk, NoMail, Relay, Site, UserVerification,
};
struct Plain;
impl Harness for Plain {
type Host = GunnarHost;
fn host(&self, _: Site, _: &Kit) -> GunnarHost {
unreachable!("the identity here is built by hand")
}
fn admin_groups(&self) -> Vec<String> {
vec![SERVER_ADMIN.to_owned()]
}
fn member_groups(&self) -> Vec<String> {
vec![SERVER_WRITE.to_owned()]
}
}
struct Rig {
plane: Arc<MemoryControlPlane>,
identity: GunnarIdentity,
}
impl Rig {
fn new() -> Rig {
Rig::with(config())
}
fn with(core: Config) -> Rig {
let plane = Arc::new(MemoryControlPlane::new());
plane
.add_principal("kim", "kim", PrincipalKind::User)
.expect("kim");
let control: Arc<dyn ControlPlane> = plane.clone();
let store = GunnarStore::new(control, IdentityTables::default(), None, system_clock());
let identity = Identity::new(
GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
core,
);
Rig { plane, identity }
}
/// kim's passkey, held at Gunnar (as the console's first-login link left it).
fn passkey(&self, seed: u8, count: u32) -> SoftPasskey {
let key = SoftPasskey::new(seed);
self.plane
.add_credential("kim", &key.id, &key.public_key(), "kim's YubiKey", count)
.expect("kim's passkey");
key
}
fn login(
&self,
key: &mut SoftPasskey,
uv: bool,
) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
let c = self.identity.login_start(&relay())?;
self.identity.login_finish(key.login(&c, ORIGIN, uv))
}
fn sessions(&self) -> usize {
self.identity.host().gunnar_store().tables().sessions.len()
}
}
fn code<T: std::fmt::Debug>(r: Result<T, nornir_identity::Refused>) -> Code {
r.expect_err("a refusal").code
}
/// ★ **A person logs in with the passkey Gunnar holds**: the session is kim's,
/// and the passkey's counter and last use move at Gunnar.
#[test]
fn a_passkey_login_is_verified_by_the_core_over_gunnars_passkey() {
let rig = Rig::new();
let mut key = rig.passkey(1, 0);
let kim = rig
.login(&mut key, true)
.expect("a genuine assertion logs kim in");
assert_eq!(kim.session.user_id, "kim");
assert!(
kim.session.stepped_up,
"a user-verified login is a fresh touch"
);
assert!(!kim.session.admin);
let stored = rig.plane.credential(&key.id).expect("read").expect("there");
assert_eq!(stored.credential.sign_count, 1, "the counter moved");
assert!(
stored.credential.last_used_unix_ms.is_some(),
"the last use is recorded"
);
assert_eq!(
rig.identity
.session(&kim.token)
.expect("the session")
.user_id,
"kim"
);
}
/// ★ **A broken or foreign assertion mints nothing**: another origin, a
/// passkey Gunnar does not hold, a ceremony answered twice.
#[test]
fn a_broken_assertion_is_refused_and_no_session_lands() {
let rig = Rig::new();
let mut key = rig.passkey(2, 0);
let c = rig.identity.login_start(&relay()).unwrap();
assert_eq!(
code(
rig.identity
.login_finish(key.login(&c, "https://evil.example", true))
),
Code::Ceremony,
"another origin"
);
let mut stranger = SoftPasskey::new(3);
assert_eq!(
code(rig.login(&mut stranger, true)),
Code::Ceremony,
"a passkey Gunnar does not hold"
);
let c = rig.identity.login_start(&relay()).unwrap();
let answer = key.login(&c, ORIGIN, true);
rig.identity
.login_finish(answer.clone())
.expect("the genuine one");
assert_eq!(
code(rig.identity.login_finish(answer)),
Code::Ceremony,
"a ceremony is single use"
);
assert_eq!(rig.sessions(), 1, "only the genuine login minted a session");
}
/// ★ **An invite's groups are Gunnar's server grant**: an admin's invite for
/// an operator (server Write) makes a new person holding Write once redeemed,
/// the invite is spent, and the new passkey logs in.
#[test]
fn an_invite_registers_a_passkey_once_applies_its_grant_and_the_passkey_logs_in() {
let rig = Rig::new();
// RA5-2 (nornir-identity 0.2.2): an invite needs a fresh step-up.
let (admin, _) = first_admin_stepped(&Plain, &rig.identity);
let invited = rig
.identity
.invite(
&admin.token,
InviteAsk {
display: "Ola".into(),
groups: vec![SERVER_WRITE.into()],
..InviteAsk::default()
},
)
.expect("an operator's invite");
let c = rig
.identity
.redeem_start(&invited.secret, "", &relay())
.expect("the ceremony");
let mut key = SoftPasskey::new(5);
let ola = rig
.identity
.redeem_finish(key.register(&c, ORIGIN))
.expect("ola's passkey");
assert_eq!(ola.session.display, "Ola");
assert!(!ola.session.admin, "an invite never makes an admin");
let id = ola.session.user_id.clone();
assert!(
rig.plane
.may(&id, &gunnar_grpc::auth::Access::Server, GrantLevel::Write)
.unwrap(),
"Write applied at Gunnar"
);
assert!(!rig
.plane
.may(&id, &gunnar_grpc::auth::Access::Server, GrantLevel::Admin)
.unwrap());
assert_eq!(
rig.plane.credentials(Some(&id)).unwrap().len(),
1,
"the passkey is Gunnar's"
);
assert_eq!(
code(rig.identity.redeem_start(&invited.secret, "", &relay())),
Code::InviteNotOpen,
"spent"
);
let again = rig.login(&mut key, false).expect("the new passkey logs in");
assert_eq!(again.session.user_id, id);
}
/// A registration whose attestation names another key than the one relayed
/// is refused, and Gunnar stores nothing.
#[test]
fn a_registration_whose_attestation_names_another_key_is_refused() {
let rig = Rig::new();
let (admin, _) = first_admin_stepped(&Plain, &rig.identity);
let link = rig
.identity
.invite(
&admin.token,
InviteAsk {
person: Some("kim".into()),
..InviteAsk::default()
},
)
.expect("kim's link");
let c = rig
.identity
.redeem_start(&link.secret, "", &relay())
.unwrap();
let mut made = SoftPasskey::new(6).register(&c, ORIGIN);
made.public_key = SoftPasskey::new(7).public_key();
assert_eq!(code(rig.identity.redeem_finish(made)), Code::Ceremony);
assert!(
rig.plane.credentials(Some("kim")).unwrap().is_empty(),
"a refused key was stored"
);
}
/// ★ **A34-7: a flood of login starts from one browser leaves every other
/// person's login alone.**
#[test]
fn a_flood_of_challenges_for_one_browser_leaves_every_other_persons_login_alone() {
let rig = Rig::new();
let flood = Relay {
client: Some(ClientKey::of_peer("203.0.113.9")),
..relay()
};
let refused = (0..200)
.find_map(|_| rig.identity.login_start(&flood).err())
.expect("one browser is bounded");
assert_eq!(refused.code, Code::Busy, "{refused:?}");
let mut key = rig.passkey(8, 0);
let elsewhere = Relay {
client: Some(ClientKey::of_peer("198.51.100.4")),
..relay()
};
let c = rig
.identity
.login_start(&elsewhere)
.expect("kim elsewhere gets a challenge");
rig.identity
.login_finish(key.login(&c, ORIGIN, true))
.expect("and logs in");
}
/// ★ **A34-12: the server decides user verification** (`--web-require-user-verification`):
/// a touch without a PIN or biometric is refused.
#[test]
fn the_server_decides_user_verification() {
let strict = Rig::with(Config {
user_verification: UserVerification::Required,
..config()
});
let mut key = strict.passkey(9, 0);
assert!(
strict
.identity
.login_start(&relay())
.unwrap()
.user_verification_required
);
assert_eq!(
code(strict.login(&mut key, false)),
Code::Ceremony,
"no UV, refused"
);
assert!(strict.login(&mut key, true).is_ok(), "UV, in");
let lenient = Rig::new();
let mut key = lenient.passkey(10, 0);
assert!(
!lenient.login(&mut key, false).unwrap().session.stepped_up,
"a plain touch is no step-up"
);
}
/// ★ WWW-CANON (owner 2026-10-30: the site is www.gunnar.rs): **a passkey registered with RP ID
/// `gunnar.rs` at the bare origin signs in at `https://www.gunnar.rs`** — the relying party stays
/// the registrable domain, so the passkeys made on the bare host keep working on www. The server's
/// allowed origins take www (and the bare origin during the change); another site is refused,
/// at the core's origin check and at the server's relying-party list.
#[test]
fn a_passkey_registered_at_the_bare_site_signs_in_at_www() {
use nornir_identity::RelyingParty;
let bare = Relay {
rp_id: "gunnar.rs".into(),
origin: "https://gunnar.rs".into(),
client: None,
};
let www = Relay {
origin: "https://www.gunnar.rs".into(),
..bare.clone()
};
let mut parties = config().parties;
parties.push(
"gunnar.rs=https://www.gunnar.rs,https://gunnar.rs"
.parse::<RelyingParty>()
.expect("www and the bare origin"),
);
let rig = Rig::with(Config {
parties,
..config()
});
let (admin, _) = first_admin_stepped(&Plain, &rig.identity);
let invited = rig
.identity
.invite(
&admin.token,
InviteAsk {
display: "Ola".into(),
groups: vec![SERVER_WRITE.into()],
..InviteAsk::default()
},
)
.expect("an invite");
// Registered on the bare host, as the owner's passkeys were.
let c = rig
.identity
.redeem_start(&invited.secret, "", &bare)
.expect("the ceremony on the bare host");
let mut key = SoftPasskey::new(9);
let ola = rig
.identity
.redeem_finish(key.register(&c, "https://gunnar.rs"))
.expect("the passkey, RP ID gunnar.rs");
// Signs in on www.
let c = rig.identity.login_start(&www).expect("a ceremony at www");
let at_www = rig
.identity
.login_finish(key.login(&c, "https://www.gunnar.rs", true))
.expect("the bare host's passkey signs in at https://www.gunnar.rs");
assert_eq!(at_www.session.user_id, ola.session.user_id);
// ...and still on the bare origin while the change rolls out.
let c = rig
.identity
.login_start(&bare)
.expect("a ceremony at the bare origin");
rig.identity
.login_finish(key.login(&c, "https://gunnar.rs", true))
.expect("the bare origin, kept during the change");
// A browser elsewhere: its client data names another origin.
let c = rig.identity.login_start(&www).unwrap();
assert_eq!(
code(
rig.identity
.login_finish(key.login(&c, "https://evil.example", true))
),
Code::Ceremony,
"another origin's assertion"
);
// A relay stating another site is not one this server answers for.
let evil = Relay {
origin: "https://evil.example".into(),
..bare.clone()
};
assert!(
rig.identity.login_start(&evil).is_err(),
"an origin outside the relying party's list"
);
}