diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 2a43198b0..c01e5660a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -40,9 +40,9 @@ - [Service Integrations](integrations/readme.md) - [LDAP](integrations/ldap.md) - [OAuth2](integrations/oauth2.md) + - [How does OAuth2 work?](integrations/oauth2/how_does_oauth2_work.md) - [Custom Claims](integrations/oauth2/custom_claims.md) - [Example Configurations](integrations/oauth2/examples.md) - - [How does OAuth2 work?](integrations/oauth2/how_does_oauth2_work.md) - [PAM and nsswitch](integrations/pam_and_nsswitch.md) - [SUSE / OpenSUSE](integrations/pam_and_nsswitch/suse.md) - [Fedora](integrations/pam_and_nsswitch/fedora.md) diff --git a/book/src/integrations/oauth2.md b/book/src/integrations/oauth2.md index 7bec976fe..5d7e37c06 100644 --- a/book/src/integrations/oauth2.md +++ b/book/src/integrations/oauth2.md @@ -229,6 +229,10 @@ kanidm system oauth2 update-scope-map nextcloud nextcloud_users email profile op > * **address** - address > * **phone** - phone_number, phone_number_verified > * **groups** - groups +> +> In addition Kanidm supports some vendor specific scopes that can include additional claims. +> +> * **ssh_publickeys** - array of ssh_publickey of the user diff --git a/proto/src/constants.rs b/proto/src/constants.rs index c6d8b3b0a..4409c0153 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -219,6 +219,7 @@ pub const ATTR_ALLOW_PRIMARY_CRED_FALLBACK: &str = "allow_primary_cred_fallback" pub const OAUTH2_SCOPE_EMAIL: &str = ATTR_EMAIL; pub const OAUTH2_SCOPE_GROUPS: &str = "groups"; +pub const OAUTH2_SCOPE_SSH_PUBLICKEYS: &str = "ssh_publickeys"; pub const OAUTH2_SCOPE_OPENID: &str = "openid"; pub const OAUTH2_SCOPE_READ: &str = "read"; pub const OAUTH2_SCOPE_SUPPLEMENT: &str = "supplement"; diff --git a/server/lib/src/entry.rs b/server/lib/src/entry.rs index 452c4f046..a9004da15 100644 --- a/server/lib/src/entry.rs +++ b/server/lib/src/entry.rs @@ -1117,6 +1117,17 @@ impl Entry { // Both invalid states can be reached from "entry -> invalidate" impl Entry { + /// This function steps back from EntryInvalid to EntryInit. + /// This is a TEST ONLY method and will never be exposed in production. + #[cfg(test)] + pub fn into_init_new(self) -> Entry { + Entry { + valid: EntryInit, + state: EntryNew, + attrs: self.attrs, + } + } + /// ⚠️ This function bypasses the schema validation and can panic if uuid is not found. /// The entry it creates can never be committed safely or replicated. /// This is a TEST ONLY method and will never be exposed in production. diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index b791d59c2..b555b3609 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -2142,6 +2142,10 @@ impl IdmServerProxyReadTransaction<'_> { } }; + if granted_scopes.contains(OAUTH2_SCOPE_SSH_PUBLICKEYS) { + pii_scopes.insert(OAUTH2_SCOPE_SSH_PUBLICKEYS.to_string()); + } + // Subsequent we then return an encrypted session handle which allows // the user to indicate their consent to this authorisation. // @@ -2854,9 +2858,23 @@ fn extra_claims_for_account( extra_claims.insert(claim_name.to_string(), claim_value.to_json_value()); } - if scopes.contains("groups") { + // Now perform our custom claim's from scopes. We do these second so that + // a user can't stomp our claim names. + + if scopes.contains(OAUTH2_SCOPE_SSH_PUBLICKEYS) { extra_claims.insert( - "groups".to_string(), + OAUTH2_SCOPE_SSH_PUBLICKEYS.to_string(), + account + .sshkeys() + .values() + .map(|pub_key| serde_json::Value::String(pub_key.to_string())) + .collect(), + ); + } + + if scopes.contains(OAUTH2_SCOPE_GROUPS) { + extra_claims.insert( + OAUTH2_SCOPE_GROUPS.to_string(), account .groups .iter() @@ -2970,7 +2988,7 @@ mod tests { JwaAlg, Jwk, JwsCompact, JwsEs256Verifier, JwsVerifier, OidcSubject, OidcUnverified, }; use kanidm_proto::constants::*; - use kanidm_proto::internal::UserAuthToken; + use kanidm_proto::internal::{SshPublicKey, UserAuthToken}; use kanidm_proto::oauth2::*; use openssl::sha; @@ -2979,6 +2997,7 @@ mod tests { use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::prelude::*; use crate::value::{AuthType, OauthClaimMapJoin, SessionState}; + use crate::valueset::{ValueSetOauthScopeMap, ValueSetSshKey}; use crate::credential::Credential; use kanidm_lib_crypto::CryptoPolicy; @@ -3126,6 +3145,7 @@ mod tests { Value::new_bool(prefer_short_username) ) ); + let ce = CreateEvent::new_internal(vec![entry_rs, entry_group, E_TESTPERSON_1.clone()]); assert!(idms_prox_write.qs_write.create(&ce).is_ok()); @@ -5237,6 +5257,137 @@ mod tests { assert_eq!(oidc.claims.get("groups"), userinfo.claims.get("groups")); } + #[idm_test] + async fn test_idm_oauth2_openid_ssh_publickey_claim( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, _uat, ident, client_uuid) = + setup_oauth2_resource_server_basic(idms, ct, true, false, true).await; + + // Extra setup for our test - add the correct claim and give an ssh publickey + // to our testperson + const ECDSA_SSH_PUBLIC_KEY: &str = "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAGyIY7o3BtOzRiJ9vvjj96bRImwmyy5GvFSIUPlK00HitiAWGhiO1jGZKmK7220Oe4rqU3uAwA00a0758UODs+0OQHLMDRtl81lzPrVSdrYEDldxH9+a86dBZhdm0e15+ODDts2LHUknsJCRRldO4o9R9VrohlF7cbyBlnhJQrR4S+Oag== william@amethyst"; + let ssh_pubkey = SshPublicKey::from_string(ECDSA_SSH_PUBLIC_KEY).unwrap(); + + let scope_set = BTreeSet::from([OAUTH2_SCOPE_SSH_PUBLICKEYS.to_string()]); + + let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); + + idms_prox_write + .qs_write + .internal_batch_modify( + [ + ( + UUID_TESTPERSON_1, + ModifyList::new_set( + Attribute::SshPublicKey, + ValueSetSshKey::new("label".to_string(), ssh_pubkey), + ), + ), + ( + client_uuid, + ModifyList::new_set( + Attribute::OAuth2RsSupScopeMap, + ValueSetOauthScopeMap::new(UUID_IDM_ALL_ACCOUNTS, scope_set), + ), + ), + ] + .into_iter(), + ) + .expect("Failed to modify test entries"); + + assert!(idms_prox_write.commit().is_ok()); + + let client_authz = ClientAuthInfo::encode_basic("test_resource_server", secret.as_str()); + + let idms_prox_read = idms.proxy_read().await.unwrap(); + + let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + + let consent_request = good_authorisation_request!( + idms_prox_read, + &ident, + ct, + code_challenge, + "openid groups".to_string() + ); + + let AuthoriseResponse::ConsentRequested { consent_token, .. } = consent_request else { + unreachable!(); + }; + + // == Manually submit the consent token to the permit for the permit_success + drop(idms_prox_read); + let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); + + let permit_success = idms_prox_write + .check_oauth2_authorise_permit(&ident, &consent_token, ct) + .expect("Failed to perform OAuth2 permit"); + + // == Submit the token exchange code. + let token_req: AccessTokenRequest = GrantTypeReq::AuthorizationCode { + code: permit_success.code, + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + // From the first step. + code_verifier, + } + .into(); + + let token_response = idms_prox_write + .check_oauth2_token_exchange(&client_authz, &token_req, ct) + .expect("Failed to perform OAuth2 token exchange"); + + let id_token = token_response.id_token.expect("No id_token in response!"); + let access_token = + JwsCompact::from_str(&token_response.access_token).expect("Invalid Access Token"); + + assert!(idms_prox_write.commit().is_ok()); + let mut idms_prox_read = idms.proxy_read().await.unwrap(); + + let mut jwkset = idms_prox_read + .oauth2_openid_publickey("test_resource_server") + .expect("Failed to get public key"); + let public_jwk = jwkset.keys.pop().expect("no such jwk"); + + let jws_validator = + JwsEs256Verifier::try_from(&public_jwk).expect("failed to build validator"); + + let oidc_unverified = + OidcUnverified::from_str(&id_token).expect("Failed to parse id_token"); + + let iat = ct.as_secs() as i64; + + let oidc = jws_validator + .verify(&oidc_unverified) + .unwrap() + .verify_exp(iat) + .expect("Failed to verify oidc"); + + // does our id_token contain the expected groups? + assert!(oidc.claims.contains_key(OAUTH2_SCOPE_SSH_PUBLICKEYS)); + + assert!(oidc + .claims + .get(OAUTH2_SCOPE_SSH_PUBLICKEYS) + .expect("unable to find key") + .as_array() + .unwrap() + .contains(&serde_json::json!(ECDSA_SSH_PUBLIC_KEY))); + + // Do the id_token details line up to the userinfo? + let userinfo = idms_prox_read + .oauth2_openid_userinfo("test_resource_server", access_token, ct) + .expect("failed to get userinfo"); + + // does the userinfo endpoint provide the same groups? + assert_eq!( + oidc.claims.get(OAUTH2_SCOPE_SSH_PUBLICKEYS), + userinfo.claims.get(OAUTH2_SCOPE_SSH_PUBLICKEYS) + ); + } + // Check insecure pkce behaviour. #[idm_test] async fn test_idm_oauth2_insecure_pkce(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) { diff --git a/server/lib/src/modify.rs b/server/lib/src/modify.rs index 2c4e96a46..d228dde1f 100644 --- a/server/lib/src/modify.rs +++ b/server/lib/src/modify.rs @@ -123,6 +123,10 @@ impl ModifyList { Self::new_list(vec![m_purge(attr)]) } + pub fn new_set(attr: Attribute, vs: ValueSet) -> Self { + Self::new_list(vec![Modify::Set(attr, vs)]) + } + pub fn push_mod(&mut self, modify: Modify) { self.mods.push(modify) }