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

nordisk/edda

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