Add ssh_publickeys as a claim for oauth2 (#3346)

Allow ssh_publickeys to be exposed as a claim for oauth2 and oidc
applications so that they can consume these keys for various uses.
An example could be something like gitlab which can then associate
the public keys with the users account.
This commit is contained in:
Firstyear 2025-01-08 18:21:28 +10:00 committed by GitHub
parent 063366cba4
commit 1a29aa7301
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 175 additions and 4 deletions

View file

@ -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)

View file

@ -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
<!-- this is just to split the templates up -->

View file

@ -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";

View file

@ -1117,6 +1117,17 @@ impl Entry<EntryInvalid, EntryCommitted> {
// Both invalid states can be reached from "entry -> invalidate"
impl Entry<EntryInvalid, EntryNew> {
/// 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<EntryInit, EntryNew> {
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.

View file

@ -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) {

View file

@ -123,6 +123,10 @@ impl ModifyList<ModifyInvalid> {
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)
}