crates/nornir-identity/tests/grpc.rs
//! The IdentityService adapter: the same answers as the core, refusals by code.
#![cfg(all(feature = "grpc", feature = "test-support"))]
use std::sync::Arc;
use nornir_identity::conformance::{config, first_admin, Harness, ORIGIN, RP_ID};
use nornir_identity::grpc::pb::identity_service_server::IdentityService;
use nornir_identity::grpc::{pb, refusal_of, IdentityGrpc};
use nornir_identity::test_host::{Kit, TestHost, TEST_ADMINS};
use nornir_identity::{Code, Identity, Site};
use tonic::Request;
struct TestHarness;
impl Harness for TestHarness {
type Host = TestHost;
fn host(&self, site: Site, kit: &Kit) -> TestHost {
TestHost::new(site, kit)
}
fn admin_groups(&self) -> Vec<String> {
vec![TEST_ADMINS.to_owned()]
}
fn member_groups(&self) -> Vec<String> {
vec!["readers".to_owned()]
}
}
#[tokio::test]
async fn a_private_site_refuses_sign_up_by_code_and_a_passkey_logs_in_through_the_service() {
let kit = Kit::default();
let core = Arc::new(Identity::new(TestHost::new(Site::Private, &kit), config()));
let svc = IdentityGrpc::new(Arc::clone(&core));
let refused = svc
.sign_up(Request::new(pb::SignUpRequest {
email: "kim@mock.invalid".into(),
display: "Kim".into(),
}))
.await
.unwrap_err();
assert_eq!(refusal_of(&refused), Some(Code::SignUpClosed));
assert_eq!(refused.code(), tonic::Code::PermissionDenied);
assert!(refused.message().starts_with("signup_closed: "));
let (_, mut key) = first_admin(&TestHarness, &core);
kit.clock.advance(1);
let c = svc
.login_start(Request::new(pb::LoginStartRequest {
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
client: "198.51.100.7".into(),
}))
.await
.unwrap()
.into_inner();
let ceremony = nornir_identity::Ceremony {
id: c.ceremony_id.clone(),
challenge: c.challenge.clone(),
rp_id: c.rp_id.clone(),
user_id: String::new(),
user_name: String::new(),
display: String::new(),
user_verification_required: c.user_verification_required,
expires_at_ms: c.expires_at_ms,
exclude: Vec::new(),
};
let a = key.login(&ceremony, ORIGIN, true);
let s = svc
.login_finish(Request::new(pb::LoginFinishRequest {
ceremony_id: a.ceremony_id,
credential_id: a.credential_id,
client_data_json: a.client_data_json,
authenticator_data: a.authenticator_data,
signature: a.signature,
rp_id: a.rp_id,
origin: a.origin,
}))
.await
.unwrap()
.into_inner();
let who = svc
.whoami(Request::new(pb::WhoamiRequest {
session: s.token.clone(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(Some(&who), s.session.as_ref());
assert!(who.admin && who.stepped_up);
svc.logout(Request::new(pb::LogoutRequest {
session: s.token.clone(),
everywhere: false,
}))
.await
.unwrap();
let gone = svc
.whoami(Request::new(pb::WhoamiRequest { session: s.token }))
.await
.unwrap_err();
assert_eq!(
(refusal_of(&gone), gone.code()),
(Some(Code::NoSession), tonic::Code::Unauthenticated)
);
}
#[tokio::test]
async fn invites_for_a_person_are_listed_withdrawn_and_every_session_ends_through_the_service() {
use nornir_identity::conformance::{redeem, step_up};
let kit = Kit::default();
let core = Arc::new(Identity::new(TestHost::new(Site::Private, &kit), config()));
let svc = IdentityGrpc::new(Arc::clone(&core));
let (admin, mut key) = first_admin(&TestHarness, &core);
// RA5-2: a session that has not stepped up mints no passkey link for the owner.
let refused = svc
.create_invite(Request::new(pb::CreateInviteRequest {
session: admin.token.clone(),
person: admin.session.user_id.clone(),
..Default::default()
}))
.await
.unwrap_err();
assert_eq!(refusal_of(&refused), Some(Code::NeedsStepUp), "{refused:?}");
assert!(
refused.message().starts_with("needs_step_up: "),
"refused by name: {}",
refused.message()
);
step_up(&core, &admin.token, &mut key).unwrap();
let made = svc
.create_invite(Request::new(pb::CreateInviteRequest {
session: admin.token.clone(),
display: "Bo".into(),
..Default::default()
}))
.await
.unwrap()
.into_inner();
let (bo, mut bo_key) = redeem(&core, &made.secret, "", 3);
step_up(&core, &bo.token, &mut bo_key).unwrap();
let again = svc
.create_invite(Request::new(pb::CreateInviteRequest {
session: bo.token.clone(),
person: bo.session.user_id.clone(),
..Default::default()
}))
.await
.unwrap()
.into_inner();
let c = svc
.redeem_start(Request::new(pb::RedeemStartRequest {
secret: again.secret.clone(),
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
..Default::default()
}))
.await
.unwrap()
.into_inner();
assert_eq!(
(c.user_id.as_str(), c.exclude_credentials.clone()),
(bo.session.user_id.as_str(), vec![bo_key.id.clone()])
);
let listed = svc
.list_invites(Request::new(pb::ListInvitesRequest {
session: bo.token.clone(),
}))
.await
.unwrap()
.into_inner();
assert_eq!(listed.invites.len(), 1);
assert_eq!(
(
listed.invites[0].invite_id.as_str(),
listed.invites[0].person.as_str()
),
(again.invite_id.as_str(), bo.session.user_id.as_str())
);
svc.revoke_invite(Request::new(pb::RevokeInviteRequest {
session: admin.token.clone(),
invite_id: again.invite_id.clone(),
}))
.await
.unwrap();
let gone = svc
.list_invites(Request::new(pb::ListInvitesRequest {
session: admin.token.clone(),
}))
.await
.unwrap()
.into_inner();
assert!(gone.invites.is_empty());
kit.clock.advance(10 * 60 + 1);
let refused = svc
.end_all_sessions(Request::new(pb::EndAllSessionsRequest {
session: admin.token.clone(),
}))
.await
.unwrap_err();
assert_eq!(refusal_of(&refused), Some(Code::NeedsStepUp));
step_up(&core, &admin.token, &mut key).unwrap();
svc.end_all_sessions(Request::new(pb::EndAllSessionsRequest {
session: admin.token.clone(),
}))
.await
.unwrap();
let ended = svc
.whoami(Request::new(pb::WhoamiRequest { session: bo.token }))
.await
.unwrap_err();
assert_eq!(refusal_of(&ended), Some(Code::NoSession));
}
/// A request as the transport hands it over from `peer`.
fn from_peer<T>(msg: T, peer: &str) -> Request<T> {
let mut req = Request::new(msg);
req.extensions_mut()
.insert(tonic::transport::server::TcpConnectInfo {
local_addr: None,
remote_addr: Some(peer.parse().expect("a socket address")),
});
req
}
fn login_start_named(client: &str) -> pb::LoginStartRequest {
pb::LoginStartRequest {
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
client: client.into(),
}
}
/// ★ **RA5-32: the per-client ceremony bound is not the caller's to name.** A caller off
/// this machine that names a fresh `client` on every call is still one client (its own
/// address), so it meets the per-client cap and cannot fill the server-wide bound that
/// makes every login answer busy. A relay on loopback (the product's own console) names
/// the browser it relays, as before; a product whose transport authenticates every peer
/// (Gunnar: mTLS off loopback) says so with `ClientTrust::EveryPeer`.
#[tokio::test]
async fn a_remote_caller_cannot_escape_the_per_client_bound_by_naming_clients() {
use nornir_identity::grpc::ClientTrust;
use nornir_identity::Limits;
let per_client = Limits::default().per_client;
let kit = Kit::default();
let core = Arc::new(Identity::new(TestHost::new(Site::Private, &kit), config()));
let svc = IdentityGrpc::new(Arc::clone(&core));
for n in 0..per_client {
svc.login_start(from_peer(
login_start_named(&format!("fake-{n}")),
"203.0.113.9:4000",
))
.await
.unwrap();
}
let refused = svc
.login_start(from_peer(
login_start_named("fake-next"),
"203.0.113.9:4001",
))
.await
.expect_err("the remote caller is one client, whatever it names");
assert_eq!(refusal_of(&refused), Some(Code::Busy));
// The console on loopback relays many browsers: each is its own client.
for n in 0..per_client + 2 {
svc.login_start(from_peer(
login_start_named(&format!("198.51.100.{n}")),
"127.0.0.1:5000",
))
.await
.unwrap();
}
// A product that authenticates every peer trusts the relay's word off loopback too.
let trusting = IdentityGrpc::new(Arc::clone(&core)).trust_clients(ClientTrust::EveryPeer);
for n in 0..per_client + 2 {
trusting
.login_start(from_peer(
login_start_named(&format!("192.0.2.{n}")),
"10.13.8.7:6000",
))
.await
.unwrap();
}
}