Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions nexus/src/app/silo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,59 @@ impl super::Nexus {
}
};

// Once the IDP metadata document is available, parse it into an
// EntityDescriptor
use samael::metadata::EntityDescriptor;
let idp_metadata: EntityDescriptor =
idp_metadata_document_string.parse().map_err(|e| {
Error::invalid_request(&format!(
"idp_metadata_document_string could not be parsed as an EntityDescriptor! {}",
e
))
})?;

// Check for at least one signing key - do not accept IDPs that have
// none!
let mut found_signing_key = false;

if let Some(idp_sso_descriptors) = idp_metadata.idp_sso_descriptors {
for idp_sso_descriptor in &idp_sso_descriptors {
for key_descriptor in &idp_sso_descriptor.key_descriptors {
// Key use is an optional attribute. If it's present, check
// if it's "signing". If it's not present, the contained key
// information could be used for either signing or
// encryption.
let is_signing_key =
if let Some(key_use) = &key_descriptor.key_use {
key_use == "signing"
} else {
true
};

if is_signing_key {
if let Some(x509_data) =
&key_descriptor.key_info.x509_data
{
if !x509_data.certificates.is_empty() {
found_signing_key = true;
break;
}
}
}
}
}
} else {
return Err(Error::invalid_request(
"no md:IDPSSODescriptor section",
));
}

if !found_signing_key {
return Err(Error::invalid_request(
"no signing key found in IDP metadata",
));
}

let provider = db::model::SamlIdentityProvider {
identity: db::model::SamlIdentityProviderIdentity::new(
Uuid::new_v4(),
Expand Down
7 changes: 7 additions & 0 deletions nexus/tests/integration_tests/data/saml_idp_descriptor.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@
<mdui:Logo height="32" width="32" xml:lang="en">https://idp.example.org/myicon.png</mdui:Logo>
</mdui:UIInfo>
</md:Extensions>
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIB0DCCAXagAwIBAgIUfvG7FgnAf1y/b0t1bJxqTJxrsA0wCgYIKoZIzj0EAwIwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjMwNjA2MTgwMzAwWhcNMjgwNjA0MTgwMzAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKd/VM8rcnguEezFNLH2XoCF46tc/9qacSrwPT17BACE6Qi2ptPRi7EJbJ2Ba5rCPKoRvVkW4Ra6N0NjbrmM6yqjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTnBe8+9wrmtOst71d7sjOSQYDDPDAKBggqhkjOPQQDAgNIADBFAiB9u01tz7C8p2W/9P39h5uf8efnYwTWmv+2m1/mvkLsygIhANTcN0dUHioQzz5C0smWnm1PhTXmxICpQzKjAxVhVavn</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.org/SAML2/SSO/Redirect"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.example.org/SAML2/SSO/POST"/>
</md:IDPSSODescriptor>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<md:EntityDescriptor entityID="https://sso.example.org/idp" validUntil="3017-08-30T19:10:29Z"
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:mdrpi="urn:oasis:names:tc:SAML:metadata:rpi"
xmlns:mdattr="urn:oasis:names:tc:SAML:metadata:attribute"
xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<md:Extensions>
<mdrpi:RegistrationInfo registrationAuthority="https://registrar.example.net"/>
<mdrpi:PublicationInfo creationInstant="2017-08-16T19:10:29Z" publisher="https://registrar.example.net"/>
<mdattr:EntityAttributes>
<saml:Attribute Name="http://registrar.example.net/entity-category" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue>https://registrar.example.net/category/self-certified</saml:AttributeValue>
</saml:Attribute>
</mdattr:EntityAttributes>
</md:Extensions>
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:Extensions>
<mdui:UIInfo>
<mdui:DisplayName xml:lang="en">Example.org</mdui:DisplayName>
<mdui:Description xml:lang="en">The identity provider at Example.org</mdui:Description>
<mdui:Logo height="32" width="32" xml:lang="en">https://idp.example.org/myicon.png</mdui:Logo>
</mdui:UIInfo>
</md:Extensions>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIB0DCCAXagAwIBAgIUfvG7FgnAf1y/b0t1bJxqTJxrsA0wCgYIKoZIzj0EAwIwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjMwNjA2MTgwMzAwWhcNMjgwNjA0MTgwMzAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKd/VM8rcnguEezFNLH2XoCF46tc/9qacSrwPT17BACE6Qi2ptPRi7EJbJ2Ba5rCPKoRvVkW4Ra6N0NjbrmM6yqjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTnBe8+9wrmtOst71d7sjOSQYDDPDAKBggqhkjOPQQDAgNIADBFAiB9u01tz7C8p2W/9P39h5uf8efnYwTWmv+2m1/mvkLsygIhANTcN0dUHioQzz5C0smWnm1PhTXmxICpQzKjAxVhVavn</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.org/SAML2/SSO/Redirect"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.example.org/SAML2/SSO/POST"/>
</md:IDPSSODescriptor>
<md:Organization>
<md:OrganizationName xml:lang="en">Example.org Non-Profit Org</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en">Example.org</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en">https://www.example.org/</md:OrganizationURL>
</md:Organization>
<md:ContactPerson contactType="technical">
<md:SurName>SAML Technical Support</md:SurName>
<md:EmailAddress>mailto:technical-support@example.org</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>
37 changes: 37 additions & 0 deletions nexus/tests/integration_tests/data/saml_idp_descriptor_no_keys.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<md:EntityDescriptor entityID="https://sso.example.org/idp" validUntil="3017-08-30T19:10:29Z"
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:mdrpi="urn:oasis:names:tc:SAML:metadata:rpi"
xmlns:mdattr="urn:oasis:names:tc:SAML:metadata:attribute"
xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<md:Extensions>
<mdrpi:RegistrationInfo registrationAuthority="https://registrar.example.net"/>
<mdrpi:PublicationInfo creationInstant="2017-08-16T19:10:29Z" publisher="https://registrar.example.net"/>
<mdattr:EntityAttributes>
<saml:Attribute Name="http://registrar.example.net/entity-category" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue>https://registrar.example.net/category/self-certified</saml:AttributeValue>
</saml:Attribute>
</mdattr:EntityAttributes>
</md:Extensions>
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:Extensions>
<mdui:UIInfo>
<mdui:DisplayName xml:lang="en">Example.org</mdui:DisplayName>
<mdui:Description xml:lang="en">The identity provider at Example.org</mdui:Description>
<mdui:Logo height="32" width="32" xml:lang="en">https://idp.example.org/myicon.png</mdui:Logo>
</mdui:UIInfo>
</md:Extensions>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.example.org/SAML2/SSO/Redirect"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.example.org/SAML2/SSO/POST"/>
</md:IDPSSODescriptor>
<md:Organization>
<md:OrganizationName xml:lang="en">Example.org Non-Profit Org</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en">Example.org</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en">https://www.example.org/</md:OrganizationURL>
</md:Organization>
<md:ContactPerson contactType="technical">
<md:SurName>SAML Technical Support</md:SurName>
<md:EmailAddress>mailto:technical-support@example.org</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>
159 changes: 159 additions & 0 deletions nexus/tests/integration_tests/saml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ type ControlPlaneTestContext =
// note: no signing keys
pub const SAML_IDP_DESCRIPTOR: &str =
include_str!("data/saml_idp_descriptor.xml");
pub const SAML_IDP_DESCRIPTOR_ENCRYPTION_KEY_ONLY: &str =
include_str!("data/saml_idp_descriptor_encryption_key_only.xml");
pub const SAML_IDP_DESCRIPTOR_NO_KEYS: &str =
include_str!("data/saml_idp_descriptor_no_keys.xml");

// Create a SAML IdP
#[nexus_test]
Expand Down Expand Up @@ -279,6 +283,120 @@ async fn test_create_a_saml_idp_invalid_descriptor_no_redirect_binding(
.expect("unexpected success");
}

// Fail to create a SAML IdP from a metadata document that only has encryption
// keys
#[nexus_test]
async fn test_create_a_saml_idp_metadata_only_encryption_keys(
cptestctx: &ControlPlaneTestContext,
) {
let client = &cptestctx.external_client;

const SILO_NAME: &str = "saml-silo";
create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlJit)
.await;

let saml_idp_descriptor =
SAML_IDP_DESCRIPTOR_ENCRYPTION_KEY_ONLY.to_string();

let server = Server::run();
server.expect(
Expectation::matching(request::method_path("GET", "/descriptor"))
.respond_with(status_code(200).body(saml_idp_descriptor)),
);

NexusRequest::new(
RequestBuilder::new(
client,
Method::POST,
&format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME),
)
.body(Some(&params::SamlIdentityProviderCreate {
identity: IdentityMetadataCreateParams {
name: "some-totally-real-saml-provider"
.to_string()
.parse()
.unwrap(),
description: "a demo provider".to_string(),
},

idp_metadata_source: params::IdpMetadataSource::Url {
url: server.url("/descriptor").to_string(),
},

idp_entity_id: "entity_id".to_string(),
sp_client_id: "client_id".to_string(),
acs_url: "http://acs".to_string(),
slo_url: "http://slo".to_string(),
technical_contact_email: "technical@fake".to_string(),

signing_keypair: None,

group_attribute_name: None,
}))
.expect_status(Some(StatusCode::BAD_REQUEST)),
)
.authn_as(AuthnMode::PrivilegedUser)
.execute()
.await
.expect("unexpected success");
}

// Fail to create a SAML IdP from a metadata document that has no keys
#[nexus_test]
async fn test_create_a_saml_idp_metadata_no_keys(
cptestctx: &ControlPlaneTestContext,
) {
let client = &cptestctx.external_client;

const SILO_NAME: &str = "saml-silo";
create_silo(&client, SILO_NAME, true, shared::SiloIdentityMode::SamlJit)
.await;

let saml_idp_descriptor = SAML_IDP_DESCRIPTOR_NO_KEYS.to_string();

let server = Server::run();
server.expect(
Expectation::matching(request::method_path("GET", "/descriptor"))
.respond_with(status_code(200).body(saml_idp_descriptor)),
);

NexusRequest::new(
RequestBuilder::new(
client,
Method::POST,
&format!("/v1/system/identity-providers/saml?silo={}", SILO_NAME),
)
.body(Some(&params::SamlIdentityProviderCreate {
identity: IdentityMetadataCreateParams {
name: "some-totally-real-saml-provider"
.to_string()
.parse()
.unwrap(),
description: "a demo provider".to_string(),
},

idp_metadata_source: params::IdpMetadataSource::Url {
url: server.url("/descriptor").to_string(),
},

idp_entity_id: "entity_id".to_string(),
sp_client_id: "client_id".to_string(),
acs_url: "http://acs".to_string(),
slo_url: "http://slo".to_string(),
technical_contact_email: "technical@fake".to_string(),

signing_keypair: None,

group_attribute_name: None,
}))
.expect_status(Some(StatusCode::BAD_REQUEST)),
)
.authn_as(AuthnMode::PrivilegedUser)
.execute()
.await
.expect("unexpected success");
}

// Create a hidden Silo with a SAML IdP
#[nexus_test]
async fn test_create_a_hidden_silo_saml_idp(
Expand Down Expand Up @@ -722,6 +840,47 @@ fn test_correct_saml_response_ecdsa_sha256() {
assert_eq!(relay_state, None);
}

// Test rejecting a SAML response signed with a key that doesn't match the silo
// identity provider's key
#[test]
fn test_reject_saml_response_signed_with_other_key() {
let silo_saml_identity_provider = SamlIdentityProvider {
idp_metadata_document_string: SAML_RESPONSE_IDP_DESCRIPTOR.to_string(),

idp_entity_id: "https://some.idp.test/oxide_rack/".to_string(),
sp_client_id: "https://customer.site/oxide_rack/saml".to_string(),
acs_url: "https://customer.site/oxide_rack/saml".to_string(),
slo_url: "http://slo".to_string(),
technical_contact_email: "technical@fake".to_string(),

public_cert: None,
private_key: None,

group_attribute_name: None,
};

let body_bytes = serde_urlencoded::to_string(SamlLoginPost {
saml_response: base64::engine::general_purpose::STANDARD
.encode(&SAML_RESPONSE_SIGNED_WITH_ECDSA_SHA256),
relay_state: None,
})
.unwrap();

let result = silo_saml_identity_provider.authenticated_subject(
&body_bytes,
// Set max_issue_delay so that SAMLResponse is valid
Some(
chrono::Utc::now()
- "2022-05-04T15:36:12.631Z"
.parse::<chrono::DateTime<chrono::Utc>>()
.unwrap()
+ chrono::Duration::seconds(60),
),
);

assert!(result.is_err());
}

// Test a SAML response with only the assertion signed
#[test]
fn test_accept_saml_response_only_assertion_signed() {
Expand Down