crates/gunnar-server/tests/sso_signin_rpc.rs
//! **Company SSO sign-in through the real entry point** (rows 45/46/77: domains, JIT,
//! rebind, the discovery door). `tests/sso.rs` drives the same verbs by calling
//! `SsoService` with a `Caller` put in by hand; here the `Sso` service is the one a box
//! serves: the real gRPC router with its gate in front, over the real
//! `CoreControlPlane` on a real store, and every call carries a credential the gate
//! must verify. The identity providers are edda's fakes, the ones `tests/sso.rs` uses
//! (`nornir_auth_oidc::fake_op`, `nornir_auth_saml::fake_idp`), reached over real HTTP
//! through gunnar's own egress.
//!
//! * OIDC: the account admin's stepped-up passkey session sets the provider and proves
//! its domain; the console (a server admin, its signed key) discovers, begins and
//! finishes; the IdP's person becomes a NEW employee just in time, and the session the
//! finish hands is a credential the gate takes as that employee (`Access.WhoAmI`).
//! The second sign-in is the same person. An IdP migration is refused until the
//! admin opens a rebind; JIT off makes nobody new. The relay's door is the console's.
//! * SAML: the same through a SAML IdP; a replayed response and an unsigned one are
//! refused and create nobody.
#![cfg(feature = "sso-egress")] // A32-18: the airgap default carries no HTTP client.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use gunnar_core::ssh_key::PrivateKey;
use gunnar_grpc::auth::SESSION_METADATA_KEY;
use gunnar_grpc::client_signer::{authed, plain_channel, ControlChannel};
use gunnar_grpc::{ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::sso_client::SsoClient;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use nornir_auth_oidc::fake_op::{self, FakeOp};
use nornir_auth_saml::fake_idp::{self, FakeIdp};
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::{Code, Request};
const ORIGIN: &str = "http://localhost:7000";
const RP_ID: &str = "localhost";
const SECRET: &str = "oidc-client-secret-NEVER-LOGGED";
fn free_addr() -> SocketAddr {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
l.local_addr().expect("addr")
}
fn mint() -> (PrivateKey, String) {
let pem = gunnar_core::mint_ed25519().expect("mint");
let k = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
let line = k.public_key().to_openssh().expect("line");
(k, line)
}
/// A software passkey (the one `tests/sso.rs` uses): P-256, user-verified.
struct Authenticator {
key: p256::ecdsa::SigningKey,
id: Vec<u8>,
}
impl Authenticator {
fn new(seed: u8, id: &[u8]) -> Authenticator {
Authenticator {
key: p256::ecdsa::SigningKey::from_slice(&[seed; 32]).unwrap(),
id: id.to_vec(),
}
}
fn public(&self) -> Vec<u8> {
self.key
.verifying_key()
.to_sec1_point(false)
.as_bytes()
.to_vec()
}
fn assert(&self, challenge: &[u8], counter: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let cd = format!(
r#"{{"type":"webauthn.get","challenge":"{}","origin":"{ORIGIN}","crossOrigin":false}}"#,
gunnar_grpc::webauthn::b64url(challenge)
)
.into_bytes();
let mut ad = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
// UP | UV: a user-verified touch, so the session it mints has stepped up.
ad.push(0x05);
ad.extend_from_slice(&counter.to_be_bytes());
let mut msg = ad.clone();
msg.extend_from_slice(&sha2::Sha256::digest(&cd));
let sig: p256::ecdsa::Signature = self.key.sign(&msg);
(cd, ad, sig.to_der().as_bytes().to_vec())
}
}
/// A user-verified passkey login through the identity core, as the console relays it:
/// the session token.
fn login(identity: &gunnar_grpc::identity::GunnarIdentity, by: &Authenticator) -> String {
let relay = nornir_identity::Relay {
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
client: None,
};
let c = identity.login_start(&relay).expect("login start");
let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
let (cd, ad, sig) = by.assert(&challenge, 1);
identity
.login_finish(nornir_identity::Assertion {
ceremony_id: c.id,
credential_id: by.id.clone(),
client_data_json: cd,
authenticator_data: ad,
signature: sig,
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
})
.expect("the passkey logs in")
.token
}
/// A call carrying a person's identity-core session, as the console sends it.
fn with_session<T>(token: &str, body: T) -> Request<T> {
let mut r = Request::new(body);
r.metadata_mut()
.insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
r
}
fn query(url: &str, key: &str) -> String {
url.split_once('?')
.map(|(_, q)| q)
.unwrap_or("")
.split('&')
.filter_map(|p| p.split_once('='))
.find(|(k, _)| *k == key)
.map(|(_, v)| {
let mut out = Vec::new();
let b = v.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
out.push(u8::from_str_radix(&v[i + 1..i + 3], 16).unwrap());
i += 3;
} else {
out.push(if b[i] == b'+' { b' ' } else { b[i] });
i += 1;
}
}
String::from_utf8(out).unwrap()
})
.unwrap_or_default()
}
/// A DNS-over-HTTPS JSON resolver on the loopback, answering `records` as the TXT
/// strings of whatever name is asked. Its URL.
fn doh_server(records: Vec<String>) -> String {
use std::io::{Read as _, Write as _};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let url = format!("http://{}/resolve", listener.local_addr().unwrap());
let answers: Vec<serde_json::Value> = records
.iter()
.map(|r| serde_json::json!({ "type": 16, "data": format!("\"{r}\"") }))
.collect();
let body = serde_json::json!({ "Status": 0, "Answer": answers }).to_string();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let mut stream = stream;
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/dns-json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
}
});
url
}
/// One box: the real router and gate on a real store, the console's signed key (the
/// box's first admin), and acme's and globex's admins, each with a passkey.
struct Rig {
_dir: tempfile::TempDir,
rt: tokio::runtime::Runtime,
stores: Arc<ServerStores>,
plane: Arc<CoreControlPlane>,
ctx: ServiceContext,
at: String,
console: PrivateKey,
acme: String,
globex: String,
stop: Option<tokio::sync::oneshot::Sender<()>>,
}
impl Drop for Rig {
fn drop(&mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
}
}
impl Rig {
fn start() -> Rig {
let dir = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let (console, console_line) = mint();
stores
.auth()
.bootstrap_admin(&console_line)
.expect("the first admin");
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
let control: Arc<dyn ControlPlane> = plane.clone();
let acme_key = Authenticator::new(6, b"acme-key");
let globex_key = Authenticator::new(7, b"globex-key");
for (account, key) in [("acme", &acme_key), ("globex", &globex_key)] {
plane
.add_principal(account, account, PrincipalKind::User)
.expect("account");
plane
.add_credential(account, &key.id, &key.public(), account, 0)
.expect("the admin's passkey");
}
let identity = gunnar_server::identity::build(
dir.path(),
Arc::clone(&control),
&ServerConfig::default(),
false,
);
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
)
.with_identity(Arc::clone(&identity));
// The box's egress to its IdP, as `gunnar-server` wires it, with the operator's
// switch on (A34-16) and the fakes' plain-HTTP loopback allowed.
ctx.sso.set_allow_loopback_http(true);
ctx.sso
.set_egress(Arc::new(gunnar_server::sso_egress::UreqEgress::new(true)));
ctx.sso.set_egress_on(true);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.enable_all()
.build()
.expect("rt");
let addr = free_addr();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let served = ctx.clone();
rt.spawn(async move {
let _ = gunnar_grpc::router(served, || 0)
.serve_with_shutdown(addr, async {
let _ = rx.await;
})
.await;
});
let t0 = Instant::now();
while std::net::TcpStream::connect(addr).is_err() {
assert!(t0.elapsed() < Duration::from_secs(10), "the box listens");
std::thread::sleep(Duration::from_millis(25));
}
let acme = login(&identity, &acme_key);
let globex = login(&identity, &globex_key);
Rig {
_dir: dir,
rt,
stores,
plane,
ctx,
at: addr.to_string(),
console,
acme,
globex,
stop: Some(tx),
}
}
fn sso(&self) -> SsoClient<ControlChannel> {
SsoClient::new(plain_channel(&self.at).expect("a channel"))
}
fn access(&self) -> AccessClient<ControlChannel> {
AccessClient::new(plain_channel(&self.at).expect("a channel"))
}
fn employees(&self, account: &str) -> Vec<String> {
let mut ids: Vec<String> = self
.plane
.employees(account)
.expect("employees")
.into_iter()
.map(|p| p.id)
.collect();
ids.sort();
ids
}
/// Begin, as the console relays it.
async fn begin(&self, account: &str) -> BeginResponse {
self.sso()
.begin(authed(
&self.console,
BeginRequest {
account: account.into(),
login_hint: String::new(),
console_origin: ORIGIN.into(),
client: String::new(),
},
))
.await
.expect("Sso.Begin through the gate")
.into_inner()
}
async fn finish(&self, request: FinishRequest) -> Result<FinishResponse, tonic::Status> {
self.sso()
.finish(authed(&self.console, request))
.await
.map(|r| r.into_inner())
}
/// One whole OIDC sign-in as `who` through the fake OP, relayed by the console.
async fn oidc_login(
&self,
op: &FakeOp,
who: fake_op::Person,
) -> Result<FinishResponse, tonic::Status> {
op.set_person(who);
let begun = self.begin("acme").await;
assert_eq!(begun.protocol, SsoProtocol::Oidc as i32);
let back = op
.authorize(&begun.redirect_url)
.expect("the fake signs in");
assert_eq!(query(&back, "state"), begun.state, "the state comes back");
self.finish(FinishRequest {
state: begun.state,
code: query(&back, "code"),
console_origin: ORIGIN.into(),
..Default::default()
})
.await
}
/// Who the gate takes `session` for: `Access.WhoAmI` through the router.
async fn who(&self, session: &str) -> Result<String, tonic::Status> {
self.access()
.who_am_i(with_session(session, WhoAmIRequest {}))
.await
.map(|r| r.into_inner().principal.map(|p| p.id).unwrap_or_default())
}
async fn set_provider(
&self,
session: &str,
provider: SsoProvider,
) -> Result<ProviderView, tonic::Status> {
self.sso()
.set_provider(with_session(
session,
SetProviderRequest {
provider: Some(provider),
client_secret: SECRET.into(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
.map(|r| r.into_inner().view.expect("a view"))
}
async fn discover(&self, hint: &str) -> DiscoverResponse {
self.sso()
.discover(authed(&self.console, DiscoverRequest { hint: hint.into() }))
.await
.expect("Sso.Discover through the gate")
.into_inner()
}
/// The account's own log, as `kind:actor:role`.
fn account_log(&self, account: &str) -> Vec<String> {
self.stores
.account_audit()
.query(
account,
&gunnar_core::account_audit::AuditQuery {
limit: 200,
..Default::default()
},
)
.expect("the account's log")
.entries
.iter()
.map(|e| format!("{}:{}:{}", e.kind, e.actor, e.actor_role))
.collect()
}
}
fn bob() -> fake_op::Person {
fake_op::Person::new("00u-bob", "bob@acme.example", "Bob Example")
}
/// ★ **OIDC sign-in through the router and gate**: the provider, the domain proof,
/// discovery, JIT, the session as a credential, the rebind, JIT off.
#[test]
fn an_oidc_sign_in_goes_through_the_real_router_and_gate() {
let b = Rig::start();
let op = FakeOp::start("gunnar-acme", SECRET).expect("fake OP");
let provider = |jit: SsoJit| SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Oidc as i32,
display_name: "acme corp".into(),
domains: vec!["acme.example".into()],
oidc: Some(OidcSettings {
issuer: op.issuer().to_owned(),
client_id: "gunnar-acme".into(),
..Default::default()
}),
console_origin: ORIGIN.into(),
jit: jit as i32,
..Default::default()
};
b.rt.block_on(async {
// The discovery door before any provider: nobody, and no account named.
let none = b.discover("bob@acme.example").await;
assert!(
!none.any_provider && !none.found && none.account.is_empty(),
"{none:?}"
);
// Only acme's admin sets acme's provider: globex's admin, stepped up, is refused,
// and so is a call with no credential.
let outsider = b
.set_provider(&b.globex, provider(SsoJit::Open))
.await
.unwrap_err();
assert_eq!(outsider.code(), Code::PermissionDenied, "{outsider:?}");
let anon = b
.sso()
.set_provider(Request::new(SetProviderRequest {
provider: Some(provider(SsoJit::Open)),
..Default::default()
}))
.await
.unwrap_err();
assert_eq!(anon.code(), Code::Unauthenticated, "{anon:?}");
let view = b
.set_provider(&b.acme, provider(SsoJit::Open))
.await
.expect("Sso.SetProvider through the gate");
assert!(view.provider.as_ref().expect("provider").has_client_secret);
assert!(
!format!("{view:?}").contains(SECRET),
"the client secret came back"
);
// The domain proof (A34-5) from the operator's resolver: until it holds, the
// domain leads nowhere; then the hint finds acme.
assert!(
!b.discover("bob@acme.example").await.found,
"an unproven domain led a sign-in"
);
let record = gunnar_grpc::sso::domains::proof_value("acme", "acme.example");
b.ctx.sso.set_dns_resolver(&doh_server(vec![record]));
let proven = b
.sso()
.verify_domain(with_session(
&b.acme,
VerifyDomainRequest {
account: "acme".into(),
domain: "acme.example".into(),
},
))
.await
.expect("Sso.VerifyDomain through the gate")
.into_inner()
.view
.expect("a view");
assert!(proven
.provider
.unwrap()
.verified_domains
.contains(&"acme.example".to_owned()));
let found = b.discover("Bob@ACME.example").await;
assert!(
found.any_provider
&& found.found
&& found.account == "acme"
&& found.display_name == "acme corp",
"{found:?}"
);
// The relay's door is the console's: the account admin's session is no relay.
let not_relay = b
.sso()
.begin(with_session(
&b.acme,
BeginRequest {
account: "acme".into(),
console_origin: ORIGIN.into(),
..Default::default()
},
))
.await
.unwrap_err();
assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");
let not_relay = b
.sso()
.discover(with_session(
&b.acme,
DiscoverRequest {
hint: "bob@acme.example".into(),
},
))
.await
.unwrap_err();
assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");
// JIT: the first sign-in makes a NEW employee of acme, and the session it hands
// is a credential the gate takes as that employee.
let first = b.oidc_login(&op, bob()).await.expect("the first sign-in");
assert!(first.created, "{first:?}");
let bob_id = first.principal.as_ref().expect("principal").id.clone();
assert_eq!(bob_id, "acme.bob");
assert_eq!(first.account, "acme");
assert_eq!(
b.stores.auth().employer_of(&bob_id).unwrap().as_deref(),
Some("acme")
);
assert_eq!(
b.who(&first.session)
.await
.expect("the session is a credential"),
"acme.bob"
);
// A session nobody handed out is no credential.
assert_eq!(
b.who("not-a-session").await.unwrap_err().code(),
Code::Unauthenticated
);
// The second sign-in is the same person.
let second = b.oidc_login(&op, bob()).await.expect("the second sign-in");
assert!(!second.created);
assert_eq!(second.principal.unwrap().id, bob_id);
assert_eq!(b.employees("acme"), std::slice::from_ref(&bob_id));
// An IdP migration (A34-11): a new subject for bob is refused, never re-pointed,
// until acme's admin opens a rebind; then it binds the SAME employee.
let mut migrated = fake_op::Person::new("new-idp-bob", "bob@acme.example", "Bob Example");
migrated.email_verified = Some(true);
let refused = b
.oidc_login(&op, migrated.clone())
.await
.expect_err("a new subject took bob");
assert!(
refused.message().contains("[sso-claim-bound]"),
"{}",
refused.message()
);
let outsider = b
.sso()
.rebind_users(with_session(
&b.globex,
RebindUsersRequest {
account: "acme".into(),
principal_ids: vec![bob_id.clone()],
..Default::default()
},
))
.await
.unwrap_err();
assert_eq!(outsider.code(), Code::PermissionDenied, "{outsider:?}");
let rebound = b
.sso()
.rebind_users(with_session(
&b.acme,
RebindUsersRequest {
account: "acme".into(),
principal_ids: vec![bob_id.clone()],
..Default::default()
},
))
.await
.expect("Sso.RebindUsers through the gate")
.into_inner();
assert!(
rebound.users.len() == 1 && rebound.users[0].rebind,
"{:?}",
rebound.users
);
let moved = b
.oidc_login(&op, migrated)
.await
.expect("the rebind lets the new IdP bind bob");
assert!(!moved.created, "a new employee was made");
assert_eq!(moved.principal.unwrap().id, bob_id);
assert_eq!(
b.who(&moved.session).await.expect("the new session"),
bob_id
);
// JIT off (A34-25): nobody new is made.
b.set_provider(&b.acme, provider(SsoJit::Off))
.await
.expect("JIT off");
let refused = b
.oidc_login(
&op,
fake_op::Person::new("00u-dan", "dan@acme.example", "Dan Example"),
)
.await
.expect_err("JIT off made an employee");
assert!(
refused.message().contains("[sso-jit-off]"),
"{}",
refused.message()
);
assert_eq!(b.employees("acme"), std::slice::from_ref(&bob_id));
});
// The account's own log holds the provider its admin set and each sign-in.
let log = b.account_log("acme");
assert!(
log.iter().any(|s| s == "sso.provider_set:acme:admin"),
"{log:#?}"
);
assert_eq!(
log.iter().filter(|s| *s == "login:acme.bob:member").count(),
3,
"{log:#?}"
);
}
/// ★ **SAML sign-in through the router and gate**: the metadata read by URL, the
/// employee made just in time, the session a credential; a replayed response and an
/// unsigned one refused, creating nobody.
#[test]
fn a_saml_sign_in_goes_through_the_real_router_and_gate() {
let b = Rig::start();
let idp = FakeIdp::start().expect("fake IdP");
b.rt.block_on(async {
let provider = SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Acme SAML".into(),
saml: Some(SamlSettings {
idp_metadata_url: idp.metadata_url(),
username_attribute: "email".into(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
};
let view = b
.set_provider(&b.acme, provider)
.await
.expect("Sso.SetProvider through the gate");
let saml = view.provider.unwrap().saml.unwrap();
assert_eq!(
saml.idp_entity_id,
idp.entity_id(),
"the metadata was read by URL"
);
assert_eq!(view.acs_url, format!("{ORIGIN}/auth/sso/saml/acs"));
let begun = b.begin("acme").await;
assert_eq!(begun.protocol, SsoProtocol::Saml as i32);
let posted = idp.respond(&begun.redirect_url).expect("the IdP answers");
assert_eq!(posted.relay_state, begun.state);
let saml_finish = |state: &str, response: &str| FinishRequest {
state: state.into(),
saml_response: response.into(),
console_origin: ORIGIN.into(),
..Default::default()
};
// The relay's door is the console's.
let not_relay = b
.sso()
.finish(with_session(
&b.acme,
saml_finish(&posted.relay_state, &posted.saml_response),
))
.await
.unwrap_err();
assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");
let ok = b
.finish(saml_finish(&posted.relay_state, &posted.saml_response))
.await
.expect("a signed response");
assert!(ok.created, "{ok:?}");
let carol = ok.principal.expect("principal").id;
assert_eq!(carol, "acme.carol");
assert_eq!(
b.who(&ok.session)
.await
.expect("the session is a credential"),
carol
);
// The same response under a fresh state: refused.
let again = b.begin("acme").await;
let replay = b
.finish(saml_finish(&again.state, &posted.saml_response))
.await
.expect_err("a replayed response");
assert_eq!(replay.code(), Code::PermissionDenied, "{replay:?}");
// An unsigned response: refused.
idp.tweak_next(fake_idp::Tweak::Unsigned);
let begun = b.begin("acme").await;
let unsigned = idp.respond(&begun.redirect_url).expect("the IdP answers");
let refused = b
.finish(saml_finish(&unsigned.relay_state, &unsigned.saml_response))
.await
.expect_err("an unsigned response");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
});
assert_eq!(b.employees("acme"), ["acme.carol".to_owned()]);
assert!(
b.account_log("acme")
.iter()
.any(|s| s == "login:acme.carol:member"),
"{:#?}",
b.account_log("acme")
);
}