#![cfg(feature = "sso-egress")]
use std::sync::{Arc, Mutex};
use gunnar_grpc::auth::{AuditLedger, AuditRecord, AuthGate, PersonSession, SESSION_METADATA_KEY};
use gunnar_grpc::identity::GunnarIdentity;
use gunnar_grpc::{Caller, ControlPlane, EventBus, ServerConfig, ServiceContext, SsoService};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::sso_server::Sso as _;
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";
#[derive(Default)]
struct Ledger(Mutex<Vec<String>>);
impl AuditLedger for Ledger {
fn record(&self, r: AuditRecord<'_>) {
self.0.lock().unwrap().push(format!(
"{} principal={} scope={} {} {}",
r.method, r.principal, r.scope, r.decision, r.reason
));
}
}
struct Harness {
_dir: tempfile::TempDir,
plane: Arc<CoreControlPlane>,
identity: Arc<GunnarIdentity>,
stores: Arc<ServerStores>,
ctx: ServiceContext,
ledger: Arc<Ledger>,
console: Caller,
op: FakeOp,
}
fn person(id: &str) -> Caller {
Caller {
principal_id: id.to_owned(),
fingerprint: String::new(),
machine: false,
authenticated: true,
}
}
fn authed<T>(caller: &Caller, body: T) -> Request<T> {
let mut r = Request::new(body);
r.extensions_mut().insert(caller.clone());
r.extensions_mut().insert(PersonSession {
stepped_up: caller.fingerprint.is_empty(),
stepped_up_at_unix_ms: None,
});
r
}
fn relay() -> nornir_identity::Relay {
nornir_identity::Relay {
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
client: None,
}
}
fn now_ms() -> i64 {
gunnar_core::now_ms()
}
fn session() -> (String, ()) {
let key =
gunnar_core::ssh_key::PrivateKey::from_openssh(gunnar_core::mint_ed25519().expect("mint"))
.expect("parses");
(key.public_key().to_openssh().expect("openssh"), ())
}
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()
}
impl Harness {
fn new() -> Harness {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
let control: Arc<dyn ControlPlane> = plane.clone();
let ledger = Arc::new(Ledger::default());
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_audit_ledger(ledger.clone())
.with_identity(Arc::clone(&identity));
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);
plane
.add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
.expect("console");
plane
.grant("gunnar-ui", "", "", GrantLevel::Admin)
.expect("the relay is a server admin");
for account in ["acme", "globex"] {
plane
.add_principal(account, account, PrincipalKind::User)
.expect("account");
}
let op = FakeOp::start("gunnar-acme", SECRET).expect("fake OP");
Harness {
_dir: dir,
plane,
identity,
stores,
ctx,
ledger,
console: Caller {
principal_id: "gunnar-ui".into(),
fingerprint: String::new(),
machine: true,
authenticated: true,
},
op,
}
}
fn sso(&self) -> SsoService {
SsoService::new(self.ctx.clone())
}
fn oidc_provider(
&self,
account: &str,
require_sso: bool,
require_passkey: bool,
) -> SsoProvider {
SsoProvider {
account: account.into(),
protocol: SsoProtocol::Oidc as i32,
display_name: format!("{account} corp"),
domains: vec![format!("{account}.example")],
oidc: Some(OidcSettings {
issuer: self.op.issuer().to_owned(),
client_id: "gunnar-acme".into(),
..Default::default()
}),
saml: None,
require_sso,
require_passkey,
console_origin: ORIGIN.into(),
..Default::default()
}
}
async fn configure(&self, provider: SsoProvider) -> ProviderView {
let account = provider.account.clone();
self.sso()
.set_provider(authed(
&person(&account),
SetProviderRequest {
provider: Some(provider),
client_secret: SECRET.into(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
.expect("the account admin configures the IdP")
.into_inner()
.view
.expect("a view")
}
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("begun")
.into_inner()
}
async fn finish_oidc(&self, state: &str, code: &str) -> Result<FinishResponse, tonic::Status> {
self.sso()
.finish(authed(
&self.console,
FinishRequest {
state: state.into(),
code: code.into(),
console_origin: ORIGIN.into(),
..Default::default()
},
))
.await
.map(|r| r.into_inner())
}
async fn oidc_login(
&self,
account: &str,
who: fake_op::Person,
) -> (Result<FinishResponse, tonic::Status>, String) {
self.op.set_person(who);
let begun = self.begin(account).await;
let back = self
.op
.authorize(&begun.redirect_url)
.expect("the fake signs in");
assert_eq!(query(&back, "state"), begun.state, "the state comes back");
let finished = self.finish_oidc(&begun.state, &query(&back, "code")).await;
let session = finished
.as_ref()
.map(|f| f.session.clone())
.unwrap_or_default();
(finished, session)
}
fn employees(&self, account: &str) -> Vec<String> {
self.plane
.employees(account)
.expect("employees")
.into_iter()
.map(|p| p.id)
.collect()
}
fn audit(&self) -> Vec<String> {
self.ledger.0.lock().unwrap().clone()
}
async fn import_as(
&self,
caller: &Caller,
account: &str,
csv: &str,
dry_run: bool,
) -> Result<ImportUsersResponse, tonic::Status> {
let mut job = self
.sso()
.import_users(authed(
caller,
ImportUsersRequest {
account: account.into(),
csv: csv.into(),
dry_run,
},
))
.await?
.into_inner();
for _ in 0..500 {
if job.state == "done" || job.state == "failed" {
return Ok(job);
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
job = self
.sso()
.import_status(authed(
caller,
ImportStatusRequest {
account: account.into(),
job_id: job.job_id.clone(),
},
))
.await?
.into_inner();
}
panic!("the import job never ended: {job:?}");
}
async fn prove(&self, account: &str, domain: &str) -> Result<ProviderView, tonic::Status> {
let record = gunnar_grpc::sso::domains::proof_value(account, domain);
self.ctx.sso.set_dns_resolver(&doh_server(vec![record]));
self.verify_domain(account, domain).await
}
async fn verify_domain(
&self,
account: &str,
domain: &str,
) -> Result<ProviderView, tonic::Status> {
self.sso()
.verify_domain(authed(
&person(account),
VerifyDomainRequest {
account: account.into(),
domain: domain.into(),
},
))
.await
.map(|r| r.into_inner().view.expect("a view"))
}
}
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
}
#[tokio::test(flavor = "multi_thread")]
async fn discover_says_whether_any_account_has_an_identity_provider() {
let h = Harness::new();
let ask = |hint: &str| {
let req = authed(&h.console, DiscoverRequest { hint: hint.into() });
let sso = h.sso();
async move {
sso.discover(req)
.await
.expect("discover answers")
.into_inner()
}
};
let none = ask("").await;
assert!(
!none.any_provider && !none.found && none.account.is_empty(),
"no provider anywhere: {none:?}"
);
assert!(
!ask("bob@acme.example").await.any_provider,
"a hint changes nothing while none is configured"
);
h.configure(h.oidc_provider("acme", false, false)).await;
h.prove("acme", "acme.example")
.await
.expect("acme proves its domain");
let any = ask("").await;
assert!(any.any_provider, "acme has a provider: {any:?}");
assert!(
!any.found && any.account.is_empty() && any.display_name.is_empty(),
"the empty hint names no account: {any:?}"
);
let other = ask("someone@elsewhere.example").await;
assert!(
other.any_provider && !other.found,
"a hint that finds nobody still says one exists: {other:?}"
);
assert!(ask("bob@acme.example").await.found);
h.sso()
.remove_provider(authed(
&person("acme"),
RemoveProviderRequest {
account: "acme".into(),
},
))
.await
.expect("the account admin removes it");
assert!(
!ask("").await.any_provider,
"the last provider removed: none any more"
);
}
fn bob() -> fake_op::Person {
fake_op::Person::new("00u-bob", "bob@acme.example", "Bob Example")
}
#[tokio::test(flavor = "multi_thread")]
async fn oidc_first_login_creates_a_new_employee_and_the_session_is_theirs() {
let h = Harness::new();
let view = h.configure(h.oidc_provider("acme", false, false)).await;
let shown = view.provider.expect("provider");
assert!(shown.has_client_secret);
assert_eq!(
view.redirect_uri,
format!("{ORIGIN}/auth/sso/oidc/callback")
);
let got = h
.sso()
.get_provider(authed(
&person("acme"),
GetProviderRequest {
account: "acme".into(),
..Default::default()
},
))
.await
.unwrap()
.into_inner();
assert!(
!format!("{got:?}").contains(SECRET),
"the client secret came back"
);
h.prove("acme", "acme.example")
.await
.expect("acme proves its domain");
let found = h
.sso()
.discover(authed(
&h.console,
DiscoverRequest {
hint: "Bob@ACME.example".into(),
},
))
.await
.unwrap()
.into_inner();
assert!(
found.found && found.account == "acme" && found.display_name == "acme corp",
"{found:?}"
);
let (first, key) = h.oidc_login("acme", bob()).await;
let first = first.expect("the first login");
let employee = first.principal.as_ref().unwrap().id.clone();
assert!(first.created);
assert_eq!(employee, "acme.bob");
assert_eq!(
h.stores.auth().employer_of(&employee).unwrap().as_deref(),
Some("acme")
);
assert_eq!(first.session, key);
assert_eq!(
h.gate_session(&key)
.expect("the core session is a credential")
.principal_id,
"acme.bob",
"the session is the employee's"
);
let (second, _) = h.oidc_login("acme", bob()).await;
let second = second.expect("the second login");
assert!(!second.created);
assert_eq!(second.principal.unwrap().id, "acme.bob");
assert_eq!(h.employees("acme"), vec!["acme.bob".to_owned()]);
let trail = h.audit().join("\n");
assert!(
trail.contains("Sso.SetProvider principal=acme scope=account:acme allow"),
"{trail}"
);
assert!(
trail.contains("Sso.Finish principal=acme.bob scope=account:acme allow")
&& trail.contains("created=true"),
"{trail}"
);
assert!(
!trail.contains(SECRET),
"the client secret reached the audit trail"
);
let log = h
.stores
.account_audit()
.query(
"acme",
&gunnar_core::account_audit::AuditQuery {
limit: 100,
..Default::default()
},
)
.expect("acme's log");
let seen: Vec<String> = log
.entries
.iter()
.map(|e| format!("{}:{}:{}", e.kind, e.actor, e.actor_role))
.collect();
assert!(
seen.iter().any(|s| s == "sso.provider_set:acme:admin"),
"{seen:#?}"
);
assert_eq!(
seen.iter()
.filter(|s| *s == "login:acme.bob:member")
.count(),
2,
"{seen:#?}"
);
assert!(
log.entries
.iter()
.filter(|e| e.kind == "login")
.all(|e| e.target == "sso" && !e.detail.contains(SECRET)),
"{:#?}",
log.entries
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_broken_id_token_creates_nobody() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
for (tweak, words) in [
(fake_op::Tweak::WrongAudience, "audience"),
(fake_op::Tweak::Expired, "expired"),
(fake_op::Tweak::ForeignKey, "does not verify"),
(fake_op::Tweak::HmacConfusion, "not allowed"),
(fake_op::Tweak::WrongNonce, "nonce"),
(fake_op::Tweak::WrongIssuer, "issuer"),
] {
h.op.tweak_next(tweak);
let (answer, _) = h.oidc_login("acme", bob()).await;
let err = answer.expect_err(&format!("{tweak:?} was accepted"));
assert_eq!(err.code(), Code::PermissionDenied, "{tweak:?}: {err:?}");
assert!(
err.message().contains(words),
"{tweak:?}: {}",
err.message()
);
assert_eq!(h.sessions(), 0, "{tweak:?} minted a session");
}
assert!(
h.employees("acme").is_empty(),
"a refused login created an employee"
);
assert!(
h.audit()
.iter()
.filter(|l| l.contains("Sso.Finish") && l.contains(" deny "))
.count()
>= 6
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_replayed_state_or_code_is_refused() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
h.op.set_person(bob());
let begun = h.begin("acme").await;
let back = h.op.authorize(&begun.redirect_url).unwrap();
h.finish_oidc(&begun.state, &query(&back, "code"))
.await
.expect("once");
let err = h
.finish_oidc(&begun.state, &query(&back, "code"))
.await
.expect_err("twice");
assert_eq!(err.code(), Code::PermissionDenied);
assert!(err.message().contains("already used"), "{}", err.message());
let begun = h.begin("acme").await;
let err = h
.finish_oidc(&begun.state, &query(&back, "code"))
.await
.expect_err("a spent code");
assert!(err.message().contains("invalid_grant"), "{}", err.message());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_subject_from_another_account_is_refused_and_never_shared() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let globex_op = FakeOp::start("gunnar-acme", SECRET).unwrap();
let mut globex = h.oidc_provider("globex", false, false);
globex.oidc.as_mut().unwrap().issuer = globex_op.issuer().to_owned();
h.configure(globex).await;
let begun = h.begin("acme").await;
let foreign = globex_op
.authorize(
&begun
.redirect_url
.replace(h.op.issuer(), globex_op.issuer()),
)
.unwrap();
let err = h
.finish_oidc(&begun.state, &query(&foreign, "code"))
.await
.expect_err("globex's code at acme");
assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
assert!(h.employees("acme").is_empty());
let (a, _) = h.oidc_login("acme", bob()).await;
let a = a.unwrap().principal.unwrap().id;
globex_op.set_person(bob());
let begun = h.begin("globex").await;
let back = globex_op.authorize(&begun.redirect_url).unwrap();
let g = h
.finish_oidc(&begun.state, &query(&back, "code"))
.await
.unwrap();
let g_id = g.principal.unwrap().id;
assert!(g.created);
assert_ne!(a, g_id);
assert_eq!(
h.stores.auth().employer_of(&g_id).unwrap().as_deref(),
Some("globex")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_existing_principal_is_never_attached() {
let h = Harness::new();
h.plane
.add_principal("acme.bob", "acme.bob", PrincipalKind::User)
.expect("a stranger");
h.configure(h.oidc_provider("acme", false, false)).await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let answer = answer.unwrap();
assert_eq!(answer.principal.unwrap().id, "acme.bob-2");
assert_eq!(
h.stores.auth().employer_of("acme.bob").unwrap(),
None,
"the stranger became acme's employee"
);
}
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 client_data(kind: &str, challenge: &[u8]) -> Vec<u8> {
format!(
r#"{{"type":"{kind}","challenge":"{}","origin":"{ORIGIN}","crossOrigin":false}}"#,
gunnar_grpc::webauthn::b64url(challenge)
)
.into_bytes()
}
fn auth_data(counter: u32) -> Vec<u8> {
let mut d = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
d.push(0x05);
d.extend_from_slice(&counter.to_be_bytes());
d
}
fn assert(&self, challenge: &[u8], counter: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let cd = Self::client_data("webauthn.get", challenge);
let ad = Self::auth_data(counter);
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())
}
}
impl Harness {
fn sessions(&self) -> usize {
self.identity.host().gunnar_store().tables().sessions.len()
}
fn gate_session(&self, token: &str) -> Result<Caller, tonic::Status> {
let mut request = Request::new(());
request
.metadata_mut()
.insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
.with_identity(Some(Arc::clone(&self.identity)))
.authenticate_request(&request, now_ms())
.map_err(|failure| failure.status())
}
fn core_login(
&self,
by: &Authenticator,
counter: u32,
) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
let c = self.identity.login_start(&relay())?;
let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
let (cd, ad, sig) = by.assert(&challenge, counter);
self.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(),
})
}
fn passkey_login(&self, by: &Authenticator, counter: u32) -> Result<String, tonic::Status> {
let login = self
.core_login(by, counter)
.map_err(nornir_identity::grpc::status)?;
self.gate_session(&login.token).map(|c| c.principal_id)
}
fn redeem(
&self,
link: &str,
by: &Authenticator,
attestation_object: Vec<u8>,
public_key: Vec<u8>,
) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
let c = self.identity.redeem_start(link, "", &relay())?;
let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
self.identity.redeem_finish(nornir_identity::Registration {
ceremony_id: c.id,
credential_id: by.id.clone(),
public_key,
client_data_json: Authenticator::client_data("webauthn.create", &challenge),
attestation_object,
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
})
}
async fn second_factor(
&self,
pending: &str,
session: &str,
) -> Result<SecondFactorResponse, tonic::Status> {
self.sso()
.second_factor(authed(
&self.console,
SecondFactorRequest {
pending: pending.into(),
session: session.into(),
},
))
.await
.map(|r| r.into_inner())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn require_sso_blocks_an_employees_passkey_but_not_the_admins() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", true, false)).await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let employee = answer.unwrap().principal.unwrap().id;
let bobs_key = Authenticator::new(3, b"bob-yubikey");
h.plane
.add_credential(&employee, &bobs_key.id, &bobs_key.public(), "bob's key", 0)
.unwrap();
let admins_key = Authenticator::new(4, b"acme-yubikey");
h.plane
.add_credential(
"acme",
&admins_key.id,
&admins_key.public(),
"acme's YubiKey",
0,
)
.unwrap();
let refused = h
.passkey_login(&bobs_key, 1)
.expect_err("bob's passkey alone");
assert_eq!(refused.code(), Code::PermissionDenied);
assert!(
refused.message().contains("Sign in with acme corp"),
"{}",
refused.message()
);
let admin = h
.passkey_login(&admins_key, 1)
.expect("the admin's break-glass");
assert_eq!(admin, "acme");
h.configure(h.oidc_provider("acme", false, false)).await;
let ok = h.passkey_login(&bobs_key, 2).expect("no longer required");
assert_eq!(ok, employee);
}
#[tokio::test(flavor = "multi_thread")]
async fn require_passkey_puts_a_passkey_after_the_idp() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", true, true)).await;
let (first, session) = h.oidc_login("acme", bob()).await;
let first = first.unwrap();
assert_eq!(
first.second_factor,
SecondFactorNeed::PasskeyRegistration as i32
);
assert!(session.is_empty(), "a session before the passkey");
assert!(
!first.enrolment.is_empty(),
"the core's link for the first passkey"
);
assert_eq!(h.sessions(), 0, "a session before the passkey");
let bobs = Authenticator::new(5, b"bob-first-key");
let relays = Authenticator::new(66, b"bob-first-key");
for (what, attestation_object) in [
(
"the attested key is bob's, the relayed one is not",
gunnar_grpc::webauthn::attestation_none(RP_ID, &bobs.id, &bobs.public(), 0),
),
("no attestation object", Vec::new()),
] {
let refused = h
.redeem(
&first.enrolment,
&relays,
attestation_object,
relays.public(),
)
.expect_err(what);
assert_eq!(
refused.code,
nornir_identity::Code::Ceremony,
"{what}: {refused:?}"
);
assert!(
h.plane.credential(&relays.id).unwrap().is_none(),
"{what}: the relay's key was stored"
);
assert_eq!(h.sessions(), 0, "{what}: a session");
}
let first_session = h
.redeem(
&first.enrolment,
&bobs,
gunnar_grpc::webauthn::attestation_none(RP_ID, &bobs.id, &bobs.public(), 0),
bobs.public(),
)
.expect("the first passkey registers through the core");
assert_eq!(first_session.session.user_id, "acme.bob");
assert_eq!(
h.gate_session(&first_session.token)
.expect_err("not yet admitted")
.code(),
Code::PermissionDenied
);
let done = h
.second_factor(&first.pending, &first_session.token)
.await
.expect("the first passkey signs in");
assert_eq!(done.principal.map(|p| p.id).as_deref(), Some("acme.bob"));
assert_eq!(
h.gate_session(&first_session.token)
.expect("admitted")
.principal_id,
"acme.bob"
);
let again = h.second_factor(&first.pending, &first_session.token).await;
assert_eq!(again.unwrap_err().code(), Code::PermissionDenied);
let (next, _) = h.oidc_login("acme", bob()).await;
let next = next.unwrap();
assert_eq!(next.second_factor, SecondFactorNeed::Passkey as i32);
assert_eq!(next.credential_ids, vec![bobs.id.clone()]);
let admins = Authenticator::new(6, b"acme-key");
h.plane
.add_credential("acme", &admins.id, &admins.public(), "acme", 0)
.unwrap();
let theirs = h.core_login(&admins, 1).expect("the admin logs in");
let wrong = h
.second_factor(&next.pending, &theirs.token)
.await
.expect_err("another person's passkey");
assert_eq!(wrong.code(), Code::PermissionDenied);
let (next, _) = h.oidc_login("acme", bob()).await;
let next = next.unwrap();
let bob_again = h.core_login(&bobs, 1).expect("bob's passkey");
h.second_factor(&next.pending, &bob_again.token)
.await
.expect("bob's passkey after the IdP");
assert_eq!(
h.gate_session(&bob_again.token)
.expect("admitted")
.principal_id,
"acme.bob"
);
h.add_own_key(
"acme.bob",
&gunnar_grpc::auth::session_fingerprint(&bob_again.token),
)
.await
.expect("an SSH key from the session the IdP admitted");
let alone = h
.core_login(&bobs, 2)
.expect("bob's passkey alone logs in at the core");
assert!(
h.gate_session(&alone.token).is_err(),
"but that session is no credential under require-SSO"
);
}
async fn saml_harness() -> (Harness, FakeIdp) {
let h = Harness::new();
let idp = FakeIdp::start().unwrap();
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 = h.configure(provider).await;
let saml = view.provider.unwrap().saml.unwrap();
assert_eq!(
saml.idp_entity_id,
idp.entity_id(),
"the metadata was read by URL"
);
assert!(
saml.idp_certificates[0].starts_with("SHA256:"),
"an answer shows fingerprints, never the certificate"
);
assert_eq!(view.acs_url, format!("{ORIGIN}/auth/sso/saml/acs"));
assert!(view.sp_metadata_xml.contains("AssertionConsumerService"));
(h, idp)
}
impl Harness {
async fn finish_saml(
&self,
state: &str,
response: &str,
) -> Result<FinishResponse, tonic::Status> {
self.sso()
.finish(authed(
&self.console,
FinishRequest {
state: state.into(),
saml_response: response.into(),
console_origin: ORIGIN.into(),
..Default::default()
},
))
.await
.map(|r| r.into_inner())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn saml_happy_path_and_every_broken_response_refused() {
let (h, idp) = saml_harness().await;
let begun = h.begin("acme").await;
assert_eq!(begun.protocol, SsoProtocol::Saml as i32);
let posted = idp.respond(&begun.redirect_url).unwrap();
assert_eq!(posted.relay_state, begun.state);
let ok = h
.finish_saml(&posted.relay_state, &posted.saml_response)
.await
.expect("a signed response");
assert!(ok.created);
assert_eq!(ok.principal.unwrap().id, "acme.carol");
let begun2 = h.begin("acme").await;
let err = h
.finish_saml(&begun2.state, &posted.saml_response)
.await
.expect_err("a replayed response");
assert_eq!(err.code(), Code::PermissionDenied);
for (tweak, words) in [
(fake_idp::Tweak::WrongAudience, "udience"),
(fake_idp::Tweak::Expired, ""),
(fake_idp::Tweak::ForeignKey, ""),
(fake_idp::Tweak::Unsigned, ""),
(fake_idp::Tweak::WrongIssuer, "issued by"),
] {
idp.tweak_next(tweak);
let begun = h.begin("acme").await;
let posted = idp.respond(&begun.redirect_url).unwrap();
let err = h
.finish_saml(&posted.relay_state, &posted.saml_response)
.await
.expect_err(&format!("{tweak:?} accepted"));
assert_eq!(err.code(), Code::PermissionDenied, "{tweak:?}: {err:?}");
assert!(
err.message().contains(words),
"{tweak:?}: {}",
err.message()
);
}
assert_eq!(h.employees("acme"), vec!["acme.carol".to_owned()]);
}
impl Harness {
async fn scim(
&self,
bearer: &str,
method: &str,
path: &str,
query: &str,
body: serde_json::Value,
) -> (u32, serde_json::Value) {
let answer = self
.sso()
.scim(authed(
&self.console,
ScimRequest {
bearer: bearer.into(),
method: method.into(),
path: path.into(),
query: query.into(),
body: if body.is_null() {
Vec::new()
} else {
serde_json::to_vec(&body).unwrap()
},
base_url: format!("{ORIGIN}/scim/v2"),
..Default::default()
},
))
.await
.expect("relayed")
.into_inner();
let json = if answer.body.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&answer.body).unwrap()
};
(answer.status, json)
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_scim_deactivation_names_the_sessions_it_ended() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let bearer = h
.sso()
.create_scim_token(authed(
&person("acme"),
CreateScimTokenRequest {
account: "acme".into(),
label: "Entra".into(),
..Default::default()
},
))
.await
.unwrap()
.into_inner()
.secret;
let raw = |method: &str, path: &str, body: serde_json::Value| ScimRequest {
bearer: bearer.clone(),
method: method.into(),
path: path.into(),
query: String::new(),
body: serde_json::to_vec(&body).unwrap(),
base_url: format!("{ORIGIN}/scim/v2"),
..Default::default()
};
let made = h
.sso()
.scim(authed(&h.console, raw("POST", "/Users", serde_json::json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "carol@acme.example", "active": true,
}))))
.await
.unwrap()
.into_inner();
let id = serde_json::from_slice::<serde_json::Value>(&made.body).unwrap()["id"]
.as_str()
.unwrap()
.to_owned();
assert!(
made.ended_sessions.is_empty(),
"a creation ends nobody's sessions"
);
let off = |active: &str| {
serde_json::json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "Replace", "path": "active", "value": active}],
})
};
let deactivated = h
.sso()
.scim(authed(
&h.console,
raw("PATCH", &format!("/Users/{id}"), off("False")),
))
.await
.unwrap()
.into_inner();
assert_eq!(deactivated.status, 200);
assert_eq!(
deactivated.ended_sessions,
vec![id.clone()],
"the deactivated employee's sessions are named"
);
let again = h
.sso()
.scim(authed(
&h.console,
raw("PATCH", &format!("/Users/{id}"), off("True")),
))
.await
.unwrap()
.into_inner();
assert!(
again.ended_sessions.is_empty(),
"a reactivation ends nothing"
);
let deleted = h
.sso()
.scim(authed(
&h.console,
raw("DELETE", &format!("/Users/{id}"), serde_json::Value::Null),
))
.await
.unwrap()
.into_inner();
assert_eq!(
deleted.ended_sessions,
vec![id],
"a deletion ends the sessions too"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn scim_creates_updates_deactivates_and_deletes_employees() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let made = h
.sso()
.create_scim_token(authed(
&person("acme"),
CreateScimTokenRequest {
account: "acme".into(),
label: "Entra".into(),
..Default::default()
},
))
.await
.unwrap()
.into_inner();
let bearer = made.secret.clone();
let listed = h
.sso()
.list_scim_tokens(authed(
&person("acme"),
ListScimTokensRequest {
account: "acme".into(),
},
))
.await
.unwrap()
.into_inner();
assert!(
!format!("{listed:?}").contains(&bearer),
"a listing shows the bearer"
);
assert!(
h.sso()
.create_scim_token(authed(
&person("globex"),
CreateScimTokenRequest {
account: "acme".into(),
label: String::new(),
..Default::default()
}
))
.await
.is_err(),
"another account's admin made acme a bearer"
);
let (status, _) = h
.scim("gscim_wrong", "GET", "/Users", "", serde_json::Value::Null)
.await;
assert_eq!(status, 401);
let (status, created) = h
.scim(
&bearer,
"POST",
"/Users",
"",
serde_json::json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "Bob@Acme.example", "externalId": "entra-1",
"name": {"givenName": "Bob", "familyName": "Example"},
"emails": [{"value": "bob@acme.example", "primary": true}], "active": true,
}),
)
.await;
assert_eq!(status, 201, "{created}");
let id = created["id"].as_str().unwrap().to_owned();
assert_eq!(id, "acme.bob");
assert_eq!(
h.stores.auth().employer_of(&id).unwrap().as_deref(),
Some("acme")
);
let (status, dup) = h
.scim(
&bearer,
"POST",
"/Users",
"",
serde_json::json!({"userName": "bob@acme.example"}),
)
.await;
assert_eq!(
(status, dup["scimType"].as_str()),
(409, Some("uniqueness"))
);
let (_, found) = h
.scim(
&bearer,
"GET",
"/Users",
"filter=userName%20eq%20%22bob%40acme.example%22",
serde_json::Value::Null,
)
.await;
assert_eq!(found["totalResults"], 1, "{found}");
h.prove("acme", "acme.example")
.await
.expect("acme proves its domain");
let mut verified_bob = bob();
verified_bob.email_verified = Some(true);
let (login, _) = h.oidc_login("acme", verified_bob).await;
let login = login.unwrap();
assert!(!login.created);
assert_eq!(login.principal.unwrap().id, id);
let session = login.session.clone();
assert!(
h.gate_session(&session).is_ok(),
"the employee's session acts"
);
let app = h
.stores
.catalog()
.create(gunnar_core::NewRepo::new(
gunnar_core::RepoRef::new("acme", "app").unwrap(),
))
.expect("acme/app");
h.plane
.grant(&id, "acme", "app", GrantLevel::Write)
.unwrap();
assert!(h
.stores
.auth()
.may(&id, &app, gunnar_core::Level::Write)
.unwrap());
let (status, patched) = h
.scim(
&bearer,
"PATCH",
&format!("/Users/{id}"),
"",
serde_json::json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "Replace", "path": "active", "value": "False"}],
}),
)
.await;
assert_eq!(status, 200, "{patched}");
assert_eq!(patched["active"], false);
assert!(
h.gate_session(&session).is_err(),
"the session survived the deactivation"
);
assert!(
h.identity.session(&session).is_err(),
"the core still holds the session"
);
assert!(
!h.stores
.auth()
.may(&id, &app, gunnar_core::Level::Write)
.unwrap(),
"a deactivated employee can push"
);
assert!(
h.stores.auth().principal(&id).unwrap().is_some(),
"the data (the employee) is kept"
);
let (refused, _) = h.oidc_login("acme", bob()).await;
assert!(refused.unwrap_err().message().contains("deactivated"));
let (status, back) = h
.scim(
&bearer,
"PATCH",
&format!("/Users/{id}"),
"",
serde_json::json!({"Operations": [{"op": "replace", "value": {"active": true}}]}),
)
.await;
assert_eq!((status, back["active"].as_bool()), (200, Some(true)));
assert!(h
.stores
.auth()
.may(&id, &app, gunnar_core::Level::Write)
.unwrap());
let (status, group) = h
.scim(
&bearer,
"POST",
"/Groups",
"",
serde_json::json!({"displayName": "Engineering", "members": [{"value": id}]}),
)
.await;
assert_eq!(status, 201, "{group}");
let gid = group["id"].as_str().unwrap().to_owned();
let (status, _) = h.scim(&bearer, "PATCH", &format!("/Groups/{gid}"), "", serde_json::json!({"Operations": [{"op": "remove", "path": format!("members[value eq \"{id}\"]")}]})).await;
assert_eq!(status, 200);
let (_, g) = h
.scim(
&bearer,
"GET",
&format!("/Groups/{gid}"),
"",
serde_json::Value::Null,
)
.await;
assert_eq!(g["members"].as_array().unwrap().len(), 0, "{g}");
let (status, _) = h
.scim(
&bearer,
"POST",
"/Groups",
"",
serde_json::json!({"displayName": "Ghosts", "members": [{"value": "globex"}]}),
)
.await;
assert_eq!(status, 400, "a member from outside the directory");
let (status, _) = h
.scim(
&bearer,
"DELETE",
&format!("/Users/{id}"),
"",
serde_json::Value::Null,
)
.await;
assert_eq!(status, 204);
let (status, _) = h
.scim(
&bearer,
"GET",
&format!("/Users/{id}"),
"",
serde_json::Value::Null,
)
.await;
assert_eq!(status, 404);
assert!(
h.stores.auth().gate_of(&id).unwrap().is_some(),
"a deleted user is still gated"
);
assert!(h.stores.auth().principal(&id).unwrap().is_some());
h.sso()
.revoke_scim_token(authed(
&person("acme"),
RevokeScimTokenRequest {
account: "acme".into(),
token_id: made.token.unwrap().token_id,
},
))
.await
.unwrap();
let (status, _) = h
.scim(&bearer, "GET", "/Users", "", serde_json::Value::Null)
.await;
assert_eq!(status, 401, "a revoked bearer still works");
let trail = h.audit().join("\n");
assert!(
!trail.contains(&bearer),
"the bearer reached the audit trail"
);
assert!(
trail.contains("Sso.Scim") && trail.contains("deactivated acme.bob"),
"{trail}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ra5_39_two_imports_at_once_are_never_one_job() {
let h = Arc::new(Harness::new());
for round in 0..200 {
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let ask = |n: u32| {
let (h, barrier) = (Arc::clone(&h), Arc::clone(&barrier));
tokio::spawn(async move {
let csv = format!(
"user_name,display_name,email,active\nr{round}n{n}@acme.example,R{n},,true\n"
);
barrier.wait().await;
h.sso()
.import_users(authed(
&person("acme"),
ImportUsersRequest {
account: "acme".into(),
csv,
dry_run: true,
},
))
.await
.map(|r| r.into_inner().job_id)
})
};
let (a, b) = (ask(1), ask(2));
let (a, b) = (a.await.unwrap(), b.await.unwrap());
if let (Ok(a), Ok(b)) = (&a, &b) {
assert_ne!(
a, b,
"round {round}: both admins were told their file is queued, as one job"
);
}
for _ in 0..500 {
match h.ctx.control.sso_import_status("acme").unwrap() {
Some(job) if job.state == "queued" || job.state == "running" => {
tokio::time::sleep(std::time::Duration::from_millis(2)).await
}
_ => break,
}
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn the_csv_import_provisions_updates_and_deactivates() {
let h = Harness::new();
let csv = "user_name,display_name,email,active\nann@acme.example,Ann,ann@acme.example,true\nben@acme.example,Ben,,true\n,nobody,,\n";
let dry = h
.import_as(&person("acme"), "acme", csv, true)
.await
.unwrap();
assert_eq!(dry.state, "done", "{dry:?}");
assert_eq!((dry.created, dry.errors.len()), (2, 1), "{dry:?}");
assert!(h.employees("acme").is_empty(), "a dry run wrote");
let done = h
.import_as(&person("acme"), "acme", csv, false)
.await
.unwrap();
assert_eq!(done.created, 2, "{done:?}");
assert_eq!(
h.employees("acme"),
vec!["acme.ann".to_owned(), "acme.ben".to_owned()]
);
let again =
"user_name,display_name,active\nann@acme.example,Ann A.,true\nben@acme.example,Ben,false\n";
let done = h
.import_as(&person("acme"), "acme", again, false)
.await
.unwrap();
assert_eq!((done.updated, done.deactivated), (1, 1), "{done:?}");
assert_eq!(done.ended_sessions, vec!["acme.ben".to_owned()], "{done:?}");
assert!(h.stores.auth().gate_of("acme.ben").unwrap().is_some());
let dir = h
.sso()
.list_directory(authed(
&person("acme"),
ListDirectoryRequest {
account: "acme".into(),
},
))
.await
.unwrap()
.into_inner();
let ann = dir
.users
.iter()
.find(|u| u.principal_id == "acme.ann")
.unwrap();
assert_eq!(
(ann.display_name.as_str(), ann.source.as_str(), ann.bound),
("Ann A.", "csv", false)
);
let err = h
.import_as(&person("globex"), "acme", csv, false)
.await
.unwrap_err();
assert_eq!(err.code(), Code::PermissionDenied);
}
#[tokio::test(flavor = "multi_thread")]
async fn only_the_account_admin_configures_its_provider() {
let h = Harness::new();
let err = h
.sso()
.set_provider(authed(
&person("globex"),
SetProviderRequest {
provider: Some(h.oidc_provider("acme", false, false)),
client_secret: SECRET.into(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
.unwrap_err();
assert_eq!(err.code(), Code::PermissionDenied);
h.configure(h.oidc_provider("acme", false, false)).await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let employee = answer.unwrap().principal.unwrap().id;
let err = h
.sso()
.get_provider(authed(
&person(&employee),
GetProviderRequest {
account: "acme".into(),
..Default::default()
},
))
.await
.unwrap_err();
assert_eq!(err.code(), Code::PermissionDenied, "{err:?}");
h.ctx.sso.set_allow_loopback_http(false);
let mut plain = h.oidc_provider("acme", false, false);
plain.oidc.as_mut().unwrap().issuer = "http://idp.example.test".into();
let err = h
.sso()
.set_provider(authed(
&person("acme"),
SetProviderRequest {
provider: Some(plain),
client_secret: String::new(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument, "{err:?}");
}
impl Harness {
async fn scim_bearer(&self, account: &str) -> String {
self.sso()
.create_scim_token(authed(
&person(account),
CreateScimTokenRequest {
account: account.into(),
label: "a34".into(),
..Default::default()
},
))
.await
.expect("a SCIM token")
.into_inner()
.secret
}
async fn provision(&self, user_name: &str, external_id: &str) -> String {
let bearer = self.scim_bearer("acme").await;
let (status, made) = self
.scim(
&bearer,
"POST",
"/Users",
"",
serde_json::json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": user_name, "externalId": external_id,
"emails": [{"value": user_name, "primary": true}], "active": true,
}),
)
.await;
assert_eq!(status, 201, "{made}");
made["id"].as_str().unwrap().to_owned()
}
fn bound(&self, id: &str) -> bool {
self.stores
.auth()
.sso_user(id)
.unwrap()
.is_some_and(|u| u.subject.is_some())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_oidc_an_unverified_email_cannot_claim_a_provisioned_employee() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let id = h.provision("bob@acme.example", "entra-1").await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let refused = answer.expect_err("an unverified e-mail claimed a provisioned employee");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("verified"),
"refused by name: {}",
refused.message()
);
assert!(!h.bound(&id), "the employee was bound");
assert_eq!(h.employees("acme"), vec![id], "a second employee was made");
assert!(
h.audit()
.iter()
.any(|l| l.contains(" deny ") && l.contains("claim")),
"the refusal is audited by name: {:?}",
h.audit()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_oidc_a_matching_preferred_username_cannot_claim() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let id = h.provision("carl@acme.example", "entra-2").await;
let mut mallory =
fake_op::Person::new("00u-mallory", "mallory@elsewhere.mock.invalid", "Mallory");
mallory.preferred_username = "carl@acme.example".into();
let (answer, _) = h.oidc_login("acme", mallory).await;
let refused = answer.expect_err("a matching preferred_username claimed carl");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("preferred_username") || refused.message().contains("user name"),
"refused by name: {}",
refused.message()
);
assert!(!h.bound(&id), "carl was bound to mallory");
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_oidc_a_stable_id_binding_claims() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let id = h.provision("bob@acme.example", "00u-bob").await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let done = answer.expect("externalId == sub claims");
assert!(!done.created);
assert_eq!(done.principal.unwrap().id, id);
assert!(h.bound(&id));
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_saml_an_email_attribute_cannot_claim_a_provisioned_employee() {
let (h, idp) = saml_harness().await;
let id = h.provision("carol@acme.example", "hr-77").await;
let begun = h.begin("acme").await;
let posted = idp.respond(&begun.redirect_url).unwrap();
let refused = h
.finish_saml(&posted.relay_state, &posted.saml_response)
.await
.expect_err("an e-mail attribute claimed a provisioned employee");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("claim"),
"refused by name: {}",
refused.message()
);
assert!(!h.bound(&id), "carol's employee was bound");
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_saml_a_persistent_name_id_binding_claims() {
let (h, idp) = saml_harness().await;
let id = h
.provision("carol@acme.example", "carol@acme.example")
.await;
let begun = h.begin("acme").await;
let posted = idp.respond(&begun.redirect_url).unwrap();
let done = h
.finish_saml(&posted.relay_state, &posted.saml_response)
.await
.expect("externalId == the persistent NameID claims");
assert!(!done.created);
assert_eq!(done.principal.unwrap().id, id);
assert!(h.bound(&id));
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_oidc_a_verified_email_in_a_claimed_domain_claims() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let id = h.provision("bob@acme.example", "hr-12").await;
let mut verified = bob();
verified.email_verified = Some(true);
let (answer, _) = h.oidc_login("acme", verified.clone()).await;
let refused = answer.expect_err("an unproven domain claimed");
assert!(
refused.message().contains("[sso-claim-domain]"),
"{}",
refused.message()
);
assert!(!h.bound(&id));
h.prove("acme", "acme.example")
.await
.expect("acme proves its domain");
let (answer, _) = h.oidc_login("acme", verified).await;
let done = answer.expect("a verified e-mail in acme.example claims");
assert!(!done.created);
assert_eq!(done.principal.unwrap().id, id);
assert!(h.bound(&id));
assert!(
h.audit().iter().any(|l| l.contains(" allow ")
&& l.contains("claimed=true")
&& l.contains("verified_email")),
"the binding is audited with its basis: {:?}",
h.audit()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_oidc_a_verified_email_outside_the_claimed_domains_cannot_claim() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let id = h.provision("dan@contractor.mock.invalid", "hr-13").await;
let mut dan = fake_op::Person::new("00u-dan", "dan@contractor.mock.invalid", "Dan");
dan.email_verified = Some(true);
let (answer, _) = h.oidc_login("acme", dan).await;
let refused = answer.expect_err("a verified e-mail outside acme's domains claimed");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("domain"),
"refused by name: {}",
refused.message()
);
assert!(!h.bound(&id), "dan was bound");
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_2_oidc_a_second_identity_cannot_steal_a_bound_employee() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let id = h.provision("bob@acme.example", "00u-bob").await;
let (first, _) = h.oidc_login("acme", bob()).await;
assert_eq!(
first
.expect("externalId == sub claims")
.principal
.unwrap()
.id,
id
);
let mut thief = fake_op::Person::new("00u-thief", "bob@acme.example", "Not Bob");
thief.email_verified = Some(true);
let (answer, _) = h.oidc_login("acme", thief).await;
let refused = answer.expect_err("a second identity took a bound employee");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("re-pointed"),
"refused by name: {}",
refused.message()
);
let user = h.stores.auth().sso_user(&id).unwrap().unwrap();
assert_eq!(
user.subject,
Some(gunnar_core::sso::subject_key(h.op.issuer(), "00u-bob")),
"the employee is still bob's"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn ra4_2_a_refused_claim_carries_its_code_and_no_email_or_employee_id() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let check = |refused: tonic::Status, secrets: &[&str], code: &str, seen: usize| {
let msg = refused.message().to_owned();
assert!(
msg.contains(code),
"the answer carries the code {code}: {msg}"
);
let audit = h.audit();
let new: Vec<&String> = audit.iter().skip(seen).collect();
assert!(
new.iter().any(|l| l.contains(code)),
"the audit row carries the code {code}: {new:?}"
);
for s in secrets {
assert!(!msg.contains(s), "the answer names {s}: {msg}");
for l in &new {
assert!(
!l.contains(s),
"the audit row (and the warn log, the same reason) names {s}: {l}"
);
}
}
};
let id = h.provision("bob@acme.example", "hr-1").await;
let seen = h.audit().len();
let (answer, _) = h.oidc_login("acme", bob()).await;
check(
answer.expect_err("refused"),
&["bob@acme.example", &id],
"[sso-claim-unverified]",
seen,
);
let id = h.provision("dan@contractor.mock.invalid", "hr-2").await;
let mut dan = fake_op::Person::new("00u-dan", "dan@contractor.mock.invalid", "Dan");
dan.email_verified = Some(true);
let seen = h.audit().len();
let (answer, _) = h.oidc_login("acme", dan).await;
check(
answer.expect_err("refused"),
&[
"dan@contractor.mock.invalid",
"contractor.mock.invalid",
&id,
],
"[sso-claim-domain]",
seen,
);
h.prove("acme", "acme.example")
.await
.expect("acme proves its domain");
let id = h.provision("eve@acme.example", "00u-eve").await;
let (first, _) = h
.oidc_login(
"acme",
fake_op::Person::new("00u-eve", "eve@acme.example", "Eve"),
)
.await;
first.expect("externalId == sub claims");
let mut thief = fake_op::Person::new("00u-thief", "eve@acme.example", "Not Eve");
thief.email_verified = Some(true);
let seen = h.audit().len();
let (answer, _) = h.oidc_login("acme", thief).await;
check(
answer.expect_err("refused"),
&["eve@acme.example", &id],
"[sso-claim-bound]",
seen,
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_10_an_sso_write_without_a_fresh_user_verified_touch_is_refused() {
let h = Harness::new();
let stale = Caller {
principal_id: "acme".to_owned(),
fingerprint: "SHA256:a34-10-no-touch".to_owned(),
machine: false,
authenticated: true,
};
let refused = h
.sso()
.set_provider(authed(
&stale,
SetProviderRequest {
provider: Some(h.oidc_provider("acme", false, false)),
client_secret: SECRET.into(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
.expect_err("SetProvider from a session with no fresh touch");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("[stepup]"),
"the console's code: {}",
refused.message()
);
let refused = h
.sso()
.create_scim_token(authed(
&stale,
CreateScimTokenRequest {
account: "acme".into(),
..Default::default()
},
))
.await
.expect_err("CreateScimToken from a session with no fresh touch");
assert!(
refused.message().contains("[stepup]"),
"{}",
refused.message()
);
let refused = h
.sso()
.remove_provider(authed(
&stale,
RemoveProviderRequest {
account: "acme".into(),
},
))
.await
.expect_err("RemoveProvider from a session with no fresh touch");
assert!(
refused.message().contains("[stepup]"),
"{}",
refused.message()
);
let set = |stepped_up: bool| {
let mut request = authed(
&stale,
SetProviderRequest {
provider: Some(h.oidc_provider("acme", false, false)),
client_secret: SECRET.into(),
clear_client_secret: false,
clear_idp_ca: false,
},
);
request.extensions_mut().insert(PersonSession {
stepped_up,
stepped_up_at_unix_ms: None,
});
let sso = h.sso();
async move { sso.set_provider(request).await }
};
set(true)
.await
.expect("a fresh user-verified touch configures the IdP");
let refused = set(false)
.await
.expect_err("a session that did not step up");
assert!(
refused.message().contains("[stepup]"),
"{}",
refused.message()
);
assert!(
h.audit().iter().any(|l| l.contains("[stepup]")),
"{:?}",
h.audit()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn the_csv_import_is_a_job_and_all_or_nothing() {
let h = Harness::new();
let first = h
.sso()
.import_users(authed(
&person("acme"),
ImportUsersRequest {
account: "acme".into(),
csv: "user_name\nann@acme.example\n".into(),
dry_run: false,
},
))
.await
.expect("the import is queued")
.into_inner();
assert!(!first.job_id.is_empty(), "{first:?}");
assert!(
["queued", "running", "done"].contains(&first.state.as_str()),
"{first:?}"
);
let mut state = first.state.clone();
for _ in 0..500 {
if state == "done" {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let now = h
.sso()
.import_status(authed(
&person("acme"),
ImportStatusRequest {
account: "acme".into(),
job_id: first.job_id.clone(),
},
))
.await
.expect("the job is still readable")
.into_inner();
assert_eq!(now.job_id, first.job_id);
state = now.state;
}
assert_eq!(state, "done");
assert_eq!(h.employees("acme"), vec!["acme.ann".to_owned()]);
let done = h
.import_as(
&person("acme"),
"acme",
"user_name\nben@acme.example\n",
false,
)
.await
.unwrap();
assert_eq!((done.state.as_str(), done.created), ("done", 1), "{done:?}");
let odd = h
.import_as(
&person("acme"),
"acme",
"user_name\n\u{430}nn@acme.example\n",
false,
)
.await
.unwrap();
assert_eq!((odd.created, odd.errors.len()), (0, 1), "{odd:?}");
let big = format!(
"user_name\n{}",
"x".repeat(gunnar_grpc::sso::directory::CSV_MAX_BYTES)
);
let err = h
.import_as(&person("acme"), "acme", &big, false)
.await
.unwrap_err();
assert!(err.message().contains("[csv_too_large]"), "{err:?}");
let before = h.employees("acme");
assert_eq!(before.len(), 2, "{before:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn scim_bearers_expire_carry_a_scope_and_refusals_are_limited() {
let h = Harness::new();
let made = h
.sso()
.create_scim_token(authed(
&person("acme"),
CreateScimTokenRequest {
account: "acme".into(),
label: "audit".into(),
expires_in_days: 30,
read_only: true,
},
))
.await
.unwrap()
.into_inner();
let token = made.token.clone().unwrap();
assert!(
token.read_only && token.expires_at_unix_ms > now_ms(),
"{token:?}"
);
let (status, _) = h
.scim(&made.secret, "GET", "/Users", "", serde_json::Value::Null)
.await;
assert_eq!(status, 200);
let (status, _) = h
.scim(
&made.secret,
"POST",
"/Users",
"",
serde_json::json!({ "userName": "eve" }),
)
.await;
assert_eq!(status, 403, "a read-only bearer wrote");
assert!(h.employees("acme").is_empty());
let err = h
.sso()
.create_scim_token(authed(
&person("acme"),
CreateScimTokenRequest {
account: "acme".into(),
label: "x".into(),
expires_in_days: 400,
read_only: false,
},
))
.await
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
let secret = "gscim_expired_for_the_test";
h.stores
.auth()
.create_scim_token(gunnar_core::sso::ScimToken {
id: "old".into(),
account: "acme".into(),
secret_hash: hex::encode(sha2::Sha256::digest(secret.as_bytes())),
label: "old".into(),
created: 1,
created_by: "acme".into(),
last_used: None,
expires: Some(2),
read_only: false,
})
.unwrap();
let (status, body) = h
.scim(secret, "GET", "/Users", "", serde_json::Value::Null)
.await;
assert_eq!(status, 401);
assert!(
body["detail"]
.as_str()
.unwrap_or_default()
.contains("expired"),
"{body}"
);
let before = h
.audit()
.iter()
.filter(|l| l.contains("198.51.100.7"))
.count();
let mut last = 0;
for _ in 0..40 {
let answer = h
.sso()
.scim(authed(
&h.console,
ScimRequest {
bearer: "gscim_nope".into(),
method: "GET".into(),
path: "/Users".into(),
base_url: format!("{ORIGIN}/scim/v2"),
client: "198.51.100.7:4711".into(),
..Default::default()
},
))
.await
.unwrap()
.into_inner();
last = answer.status;
}
assert_eq!(last, 429, "a flood of refused bearers is throttled");
let rows = h
.audit()
.iter()
.filter(|l| l.contains("198.51.100.7"))
.count()
- before;
assert!(
rows <= gunnar_grpc::sso::scim::bearer::AUDITED_PER_MINUTE as usize,
"{rows} audit rows"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn the_console_addresses_are_the_servers() {
let h = Harness::new();
let got = h
.sso()
.get_provider(authed(
&person("acme"),
GetProviderRequest {
account: "acme".into(),
console_origin: "https://console.example/".into(),
},
))
.await
.expect("read")
.into_inner();
assert!(got.view.is_none(), "acme has no provider yet");
let a = got.addresses.expect("the addresses, provider or not");
assert_eq!(
a.redirect_uri,
"https://console.example/auth/sso/oidc/callback"
);
assert_eq!(a.acs_url, "https://console.example/auth/sso/saml/acs");
assert_eq!(a.sp_entity_id, "https://console.example/auth/sso/saml/acme");
assert_eq!(a.login_url, "https://console.example/login/sso/acme");
assert_eq!(a.scim_base_url, "https://console.example/scim/v2");
assert_eq!(
gunnar_grpc::sso::oidc::gunnar_core_paths::segment("a b/c"),
"a%20b%2Fc"
);
let none = h
.sso()
.get_provider(authed(
&person("acme"),
GetProviderRequest {
account: "acme".into(),
..Default::default()
},
))
.await
.expect("read")
.into_inner();
assert!(none.addresses.is_none());
h.ctx.sso.set_allow_loopback_http(false);
let err = h
.sso()
.get_provider(authed(
&person("acme"),
GetProviderRequest {
account: "acme".into(),
console_origin: "http://console.example".into(),
},
))
.await
.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
}
impl Harness {
async fn resave(&self, provider: SsoProvider) -> Result<ProviderView, tonic::Status> {
let account = provider.account.clone();
self.sso()
.set_provider(authed(
&person(&account),
SetProviderRequest {
provider: Some(provider),
client_secret: String::new(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
.map(|r| r.into_inner().view.expect("a view"))
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_22_a_pasted_saml_provider_is_saved_again_without_its_metadata() {
let h = Harness::new();
let idp = FakeIdp::start().unwrap();
let first = h
.configure(SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Acme SAML".into(),
saml: Some(SamlSettings {
idp_metadata_xml: idp.metadata_xml(),
username_attribute: "email".into(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
})
.await;
let stored = first.provider.unwrap().saml.unwrap();
assert_eq!(stored.idp_entity_id, idp.entity_id());
let again = h
.resave(SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Acme SAML".into(),
saml: Some(SamlSettings {
username_attribute: "email".into(),
..Default::default()
}),
require_sso: true,
console_origin: ORIGIN.into(),
..Default::default()
})
.await
.expect("saved again without the XML");
let p = again.provider.unwrap();
assert!(p.require_sso, "the toggle was saved");
let saml = p.saml.unwrap();
assert_eq!(
saml.idp_entity_id, stored.idp_entity_id,
"the stored entity id is kept"
);
assert_eq!(
saml.idp_sso_url, stored.idp_sso_url,
"the stored sign-on URL is kept"
);
assert_eq!(
saml.idp_certificates, stored.idp_certificates,
"the stored certificates are kept"
);
let begun = h.begin("acme").await;
let posted = idp.respond(&begun.redirect_url).unwrap();
h.finish_saml(&posted.relay_state, &posted.saml_response)
.await
.expect("a signed response after the re-save");
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_48_an_unsent_display_name_claim_or_attribute_is_kept() {
let h = Harness::new();
let mut oidc = h.oidc_provider("acme", false, false);
oidc.oidc.as_mut().unwrap().display_name_claim = "nickname".into();
h.configure(oidc).await;
let again = h
.resave(h.oidc_provider("acme", true, false))
.await
.expect("saved again");
assert_eq!(
again.provider.unwrap().oidc.unwrap().display_name_claim,
"nickname",
"the stored claim is kept"
);
let idp = FakeIdp::start().unwrap();
let saml = |attribute: &str| SsoProvider {
account: "globex".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Globex SAML".into(),
saml: Some(SamlSettings {
idp_metadata_xml: idp.metadata_xml(),
display_name_attribute: attribute.into(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
};
h.configure(saml("displayName")).await;
let again = h.resave(saml("")).await.expect("saved again");
assert_eq!(
again.provider.unwrap().saml.unwrap().display_name_attribute,
"displayName",
"the stored attribute is kept"
);
}
impl Harness {
async fn add_own_key(
&self,
employee: &str,
with: &str,
) -> Result<AddKeyResponse, tonic::Status> {
let (fresh, _) = session();
let caller = Caller {
principal_id: employee.into(),
fingerprint: with.into(),
machine: false,
authenticated: true,
};
gunnar_grpc::AccessService::new(self.ctx.clone())
.add_key(authed(
&caller,
AddKeyRequest {
principal_id: employee.into(),
openssh_public_key: fresh,
comment: "new laptop".into(),
..Default::default()
},
))
.await
.map(|r| r.into_inner())
}
async fn add_own_passkey(
&self,
employee: &str,
with: &str,
seed: u8,
) -> Result<AddCredentialResponse, tonic::Status> {
let key = Authenticator::new(seed, &[seed; 16]);
let caller = Caller {
principal_id: employee.into(),
fingerprint: with.into(),
machine: false,
authenticated: true,
};
gunnar_grpc::AccessService::new(self.ctx.clone())
.add_credential(authed(
&caller,
AddCredentialRequest {
principal_id: employee.into(),
credential_id: key.id.clone(),
public_key: key.public(),
label: format!("key {seed}"),
sign_count: 0,
},
))
.await
.map(|r| r.into_inner())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_24_require_sso_refuses_a_credential_minted_from_a_key_that_bypasses_the_idp() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", true, false)).await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let answer = answer.expect("bob signs in through the IdP");
let employee = answer.principal.unwrap().id;
assert!(
!answer.session.is_empty(),
"the IdP sign-in minted a core session"
);
let through_the_idp = gunnar_grpc::auth::session_fingerprint(&answer.session);
let (laptop, _) = session();
let old = h
.plane
.add_key(&employee, &laptop, "old laptop", None, &employee, None)
.expect("an SSH key of bob's");
let refused = h
.add_own_key(&employee, &old.fingerprint)
.await
.expect_err("an SSH key from an SSH key");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("Sign in with acme corp"),
"refused by name: {}",
refused.message()
);
let refused = h
.add_own_passkey(&employee, &old.fingerprint, 21)
.await
.expect_err("a passkey from an SSH key");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
h.add_own_key(&employee, &through_the_idp)
.await
.expect("an SSH key from the IdP sign-in");
h.add_own_passkey(&employee, &through_the_idp, 22)
.await
.expect("a passkey from the IdP sign-in");
assert!(
h.audit()
.iter()
.any(|l| l.starts_with("Access.AddKey") && l.contains(" deny")),
"the refusal is audited: {:?}",
h.audit()
);
h.configure(h.oidc_provider("acme", false, false)).await;
h.add_own_key(&employee, &old.fingerprint)
.await
.expect("no longer required");
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_24_require_sso_takes_a_core_session_only_from_an_idp_sign_in() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", true, false)).await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let employee = answer
.expect("bob signs in through the IdP")
.principal
.unwrap()
.id;
let by_passkey = gunnar_grpc::auth::session_fingerprint("nis1.a-passkey-login");
assert!(by_passkey.starts_with("session:"), "{by_passkey}");
let admitted = || {
let s = gunnar_grpc::identity::vouched_session(&h.identity, &employee, false)
.expect("a core session");
h.identity
.host()
.gunnar_store()
.admit_sso_session(&s.token)
.expect("admitted");
s.token
};
let by_the_idp = gunnar_grpc::auth::session_fingerprint(&admitted());
let old = admitted();
h.identity
.logout(&old, false)
.expect("the old sign-in ends");
let ran_out = gunnar_grpc::auth::session_fingerprint(&old);
let refused = h
.add_own_key(&employee, &by_passkey)
.await
.expect_err("an SSH key from a passkey-login core session");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert!(
refused.message().contains("Sign in with acme corp"),
"refused by name: {}",
refused.message()
);
let refused = h
.add_own_passkey(&employee, &ran_out, 23)
.await
.expect_err("a passkey from an admitted core session that has ended");
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
h.add_own_key(&employee, &by_the_idp)
.await
.expect("an SSH key from the IdP sign-in's core session");
h.add_own_passkey(&employee, &by_the_idp, 24)
.await
.expect("a passkey from the IdP sign-in's core session");
}
#[tokio::test(flavor = "multi_thread")]
async fn ra5_38_an_sso_admitted_session_mints_its_own_credential_after_a_restart() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", true, false)).await;
let (answer, token) = h.oidc_login("acme", bob()).await;
let employee = answer
.expect("bob signs in through the IdP")
.principal
.unwrap()
.id;
assert!(!token.is_empty(), "the IdP sign-in's core session");
assert!(h.gate_session(&token).is_ok(), "the admitted session acts");
let restarted = ServiceContext::new(
Arc::clone(&h.ctx.control),
Arc::new(EventBus::new(16)),
ServerConfig::default(),
)
.with_audit_ledger(h.ledger.clone())
.with_identity(Arc::clone(&h.identity));
let (fresh, _) = session();
let caller = Caller {
principal_id: employee.clone(),
fingerprint: gunnar_grpc::auth::session_fingerprint(&token),
machine: false,
authenticated: true,
};
gunnar_grpc::AccessService::new(restarted)
.add_key(authed(
&caller,
AddKeyRequest {
principal_id: employee,
openssh_public_key: fresh,
comment: "after the restart".into(),
..Default::default()
},
))
.await
.expect("the admitted session mints its own key after a restart");
}
fn proof_of<'a>(view: &'a ProviderView, domain: &str) -> &'a DomainProof {
view.domain_proofs
.iter()
.find(|p| p.domain == domain)
.unwrap_or_else(|| panic!("no proof for {domain}: {view:?}"))
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_5_a_domain_routes_only_after_its_dns_proof() {
let h = Harness::new();
let ask = |hint: &str| {
let req = authed(&h.console, DiscoverRequest { hint: hint.into() });
let sso = h.sso();
async move {
sso.discover(req)
.await
.expect("discover answers")
.into_inner()
}
};
let view = h.configure(h.oidc_provider("acme", false, false)).await;
let proof = proof_of(&view, "acme.example");
assert!(!proof.verified, "{proof:?}");
assert_eq!(proof.txt_name, "_sso-proof.acme.example");
assert_eq!(
proof.txt_value,
gunnar_grpc::sso::domains::proof_value("acme", "acme.example")
);
assert!(
!ask("bob@acme.example").await.found,
"an unproven domain routed a sign-in"
);
assert!(!ask("acme.example").await.found);
assert!(
!ask("acme").await.found,
"an account name is no hint (A34-39)"
);
let mut squat = h.oidc_provider("globex", false, false);
squat.domains = vec!["acme.example".into()];
h.configure(squat).await;
h.ctx
.sso
.set_dns_resolver(&doh_server(vec!["v=spf1 -all".into()]));
let refused = h
.verify_domain("acme", "acme.example")
.await
.expect_err("no record, no proof");
assert!(
refused.message().contains("[sso-domain-unproven]"),
"{}",
refused.message()
);
h.ctx
.sso
.set_dns_resolver(&doh_server(vec![gunnar_grpc::sso::domains::proof_value(
"globex",
"acme.example",
)]));
assert!(h.verify_domain("acme", "acme.example").await.is_err());
let view = h
.prove("acme", "acme.example")
.await
.expect("the record is there");
assert!(proof_of(&view, "acme.example").verified);
assert!(view
.provider
.as_ref()
.unwrap()
.verified_domains
.contains(&"acme.example".to_owned()));
let found = ask("Bob@ACME.example").await;
assert!(found.found && found.account == "acme", "{found:?}");
assert!(ask("acme.example").await.found);
assert!(
!ask("acme").await.found,
"an account name is still no hint (A34-39)"
);
let refused = h
.prove("globex", "acme.example")
.await
.expect_err("a second account proved a taken domain");
assert!(
refused.message().contains("[sso-domain-taken]"),
"{}",
refused.message()
);
assert_eq!(ask("bob@acme.example").await.account, "acme");
let mut public = h.oidc_provider("acme", false, false);
public.domains = vec!["gmail.com".into()];
let refused = h
.sso()
.set_provider(authed(
&person("acme"),
SetProviderRequest {
provider: Some(public),
..Default::default()
},
))
.await
.expect_err("gmail.com was taken as a company domain");
assert!(
refused.message().contains("[sso-domain-public]"),
"{}",
refused.message()
);
assert!(
h.audit()
.iter()
.any(|l| l.contains("Sso.VerifyDomain") && l.contains("allow domain=acme.example")),
"the proof is audited: {:?}",
h.audit()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_11_a_rebind_lets_the_new_idp_bind_the_same_employee() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
h.prove("acme", "acme.example")
.await
.expect("acme proves its domain");
let (first, _) = h.oidc_login("acme", bob()).await;
let bob_id = first.expect("bob's first sign-in").principal.unwrap().id;
let mut migrated = fake_op::Person::new("new-idp-bob", "bob@acme.example", "Bob Example");
migrated.email_verified = Some(true);
let (answer, _) = h.oidc_login("acme", migrated.clone()).await;
let refused = answer.expect_err("a new subject took bob");
assert!(
refused.message().contains("[sso-claim-bound]"),
"{}",
refused.message()
);
let rebound = h
.sso()
.rebind_users(authed(
&person("acme"),
RebindUsersRequest {
account: "acme".into(),
principal_ids: vec![bob_id.clone()],
..Default::default()
},
))
.await
.expect("the account admin opens a rebind")
.into_inner();
assert_eq!(rebound.users.len(), 1);
assert!(
rebound.users[0].rebind && !rebound.users[0].bound,
"{:?}",
rebound.users
);
let (answer, _) = h.oidc_login("acme", migrated).await;
let done = answer.expect("the rebind lets the new IdP bind bob");
assert!(!done.created, "a new employee was made");
assert_eq!(done.principal.unwrap().id, bob_id);
assert_eq!(h.employees("acme"), vec![bob_id.clone()]);
let user = h.stores.auth().sso_user(&bob_id).unwrap().unwrap();
assert_eq!(
user.subject,
Some(gunnar_core::sso::subject_key(h.op.issuer(), "new-idp-bob"))
);
let (old, _) = h.oidc_login("acme", bob()).await;
assert_ne!(
old.ok().and_then(|d| d.principal).map(|p| p.id),
Some(bob_id),
"the old subject still signs bob in"
);
assert!(
h.audit()
.iter()
.any(|l| l.contains("Sso.RebindUsers") && l.contains("allow employees=1")),
"the rebind is audited: {:?}",
h.audit()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_25_jit_follows_the_providers_policy() {
let h = Harness::new();
let mut off = h.oidc_provider("acme", false, false);
off.jit = SsoJit::Off as i32;
let view = h.configure(off).await;
assert_eq!(view.provider.unwrap().jit, SsoJit::Off as i32);
h.prove("acme", "acme.example")
.await
.expect("acme proves its domain");
let (answer, _) = h.oidc_login("acme", bob()).await;
let refused = answer.expect_err("JIT off made an employee");
assert!(
refused.message().contains("[sso-jit-off]"),
"{}",
refused.message()
);
assert!(h.employees("acme").is_empty());
let mut domains = h.oidc_provider("acme", false, false);
domains.jit = SsoJit::Domains as i32;
h.configure(domains).await;
let (answer, _) = h.oidc_login("acme", bob()).await;
let refused = answer.expect_err("an unverified e-mail was made an employee");
assert!(
refused.message().contains("[sso-jit-domain]"),
"{}",
refused.message()
);
let mut verified = bob();
verified.email_verified = Some(true);
let (answer, _) = h.oidc_login("acme", verified).await;
assert!(
answer
.expect("a verified e-mail in a proven domain")
.created
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a34_46_a_deactivated_sign_in_is_refused_by_code() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let (first, _) = h.oidc_login("acme", bob()).await;
let id = first.expect("bob's first sign-in").principal.unwrap().id;
h.sso()
.set_user_active(authed(
&person("acme"),
SetUserActiveRequest {
account: "acme".into(),
principal_id: id.clone(),
active: false,
},
))
.await
.expect("the admin deactivates bob");
let (answer, _) = h.oidc_login("acme", bob()).await;
let refused = answer.expect_err("a deactivated person signed in");
assert!(
refused.message().contains("[sso-deactivated]"),
"{}",
refused.message()
);
assert!(
!refused.message().contains(&id),
"the refusal names the employee: {}",
refused.message()
);
}
#[derive(Default)]
struct Dialled(Mutex<Vec<String>>);
impl gunnar_grpc::sso::Egress for Dialled {
fn get(&self, url: &str) -> Result<(u16, String), String> {
self.0.lock().unwrap().push(url.to_owned());
Err("this test dials nothing".into())
}
fn post_form(&self, url: &str, _: &str, _: &str) -> Result<(u16, String), String> {
self.0.lock().unwrap().push(url.to_owned());
Err("this test dials nothing".into())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn ra5_24_an_unfed_resolver_refuses_the_domain_proof_and_dials_nobody() {
let h = Harness::new();
h.configure(h.oidc_provider("acme", false, false)).await;
let dialled = Arc::new(Dialled::default());
h.ctx.sso.set_egress(dialled.clone());
h.ctx.sso.set_dns_resolver("");
let refused = h
.verify_domain("acme", "acme.example")
.await
.expect_err("no resolver, no proof");
assert!(
refused.message().contains("[sso-domain-resolver]"),
"{}",
refused.message()
);
assert!(
refused.message().contains("feed sso_dns_resolver"),
"{}",
refused.message()
);
assert_eq!(
*dialled.0.lock().unwrap(),
Vec::<String>::new(),
"a resolver nobody configured was dialled"
);
}