From 1de1b2db3bd80a773a74a2f0ced8974a95cb291f Mon Sep 17 00:00:00 2001 From: Firstyear Date: Tue, 29 Jun 2021 14:23:39 +1000 Subject: [PATCH] Add the ability to configure and provide Oauth2 authentication for Kanidm. (#485) --- Cargo.lock | 34 +- designs/oauth.rst | 18 +- kanidm_client/Cargo.toml | 4 +- kanidm_client/src/asynchronous.rs | 180 ++- kanidm_client/src/lib.rs | 39 + kanidm_client/tests/default_entries.rs | 5 +- kanidm_client/tests/oauth2_test.rs | 148 +++ kanidm_client/tests/proto_v1_test.rs | 78 +- kanidm_proto/Cargo.toml | 1 + kanidm_proto/src/lib.rs | 1 + kanidm_proto/src/oauth2.rs | 87 ++ kanidm_proto/src/v1.rs | 29 +- kanidm_tools/src/cli/lib.rs | 17 + kanidm_tools/src/cli/oauth2.rs | 48 + kanidm_tools/src/cli/session.rs | 2 +- kanidm_tools/src/opt/kanidm.rs | 37 + kanidmd/Cargo.toml | 4 +- kanidmd/server.toml | 2 +- kanidmd/src/lib/actors/v1_read.rs | 117 +- kanidmd/src/lib/actors/v1_write.rs | 66 +- kanidmd/src/lib/be/dbvalue.rs | 3 + kanidmd/src/lib/constants/acp.rs | 55 + kanidmd/src/lib/constants/entries.rs | 12 + kanidmd/src/lib/constants/mod.rs | 2 +- kanidmd/src/lib/constants/schema.rs | 204 +++- kanidmd/src/lib/constants/uuids.rs | 22 +- .../src/lib/core/{https.rs => https/mod.rs} | 137 ++- kanidmd/src/lib/core/https/oauth2.rs | 340 ++++++ kanidmd/src/lib/core/mod.rs | 4 +- kanidmd/src/lib/entry.rs | 13 +- kanidmd/src/lib/event.rs | 9 +- kanidmd/src/lib/identity.rs | 2 +- kanidmd/src/lib/idm/account.rs | 14 +- kanidmd/src/lib/idm/mod.rs | 1 + kanidmd/src/lib/idm/oauth2.rs | 1021 +++++++++++++++++ kanidmd/src/lib/idm/radius.rs | 2 +- kanidmd/src/lib/idm/server.rs | 81 +- kanidmd/src/lib/idm/unix.rs | 4 +- kanidmd/src/lib/lib.rs | 4 + kanidmd/src/lib/macros.rs | 6 +- kanidmd/src/lib/modify.rs | 25 + kanidmd/src/lib/plugins/mod.rs | 5 + kanidmd/src/lib/plugins/oauth2.rs | 139 +++ kanidmd/src/lib/schema.rs | 6 +- kanidmd/src/lib/server.rs | 102 +- kanidmd/src/lib/value.rs | 129 ++- 46 files changed, 3049 insertions(+), 210 deletions(-) create mode 100644 kanidm_client/tests/oauth2_test.rs create mode 100644 kanidm_proto/src/oauth2.rs create mode 100644 kanidm_tools/src/cli/oauth2.rs rename kanidmd/src/lib/core/{https.rs => https/mod.rs} (93%) create mode 100644 kanidmd/src/lib/core/https/oauth2.rs create mode 100644 kanidmd/src/lib/idm/oauth2.rs create mode 100644 kanidmd/src/lib/plugins/oauth2.rs diff --git a/Cargo.lock b/Cargo.lock index 48048ba78..d8ede3c64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,9 +1469,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" dependencies = [ "libc", ] @@ -1793,11 +1793,13 @@ name = "kanidm_client" version = "1.1.0-alpha.4" dependencies = [ "async-std", + "base64 0.13.0", "env_logger", "futures", "kanidm", "kanidm_proto", "log", + "oauth2", "reqwest", "serde", "serde_derive", @@ -1819,6 +1821,7 @@ dependencies = [ "serde_derive", "serde_json", "time 0.2.27", + "url", "uuid", "webauthn-rs", ] @@ -2184,6 +2187,24 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ad4d0136960353683efa6160b9c867088b4b8f567b762cd37420a10ce32703" +dependencies = [ + "base64 0.12.3", + "chrono", + "http", + "rand 0.7.3", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.9.5", + "thiserror", + "url", +] + [[package]] name = "once_cell" version = "1.8.0" @@ -3000,6 +3021,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f6109f0506e20f7e0f910e51a0079acf41da8e0694e6442527c4ddf5a2b158" +dependencies = [ + "serde", +] + [[package]] name = "serde_qs" version = "0.7.2" diff --git a/designs/oauth.rst b/designs/oauth.rst index ea8101fde..d53f315a5 100644 --- a/designs/oauth.rst +++ b/designs/oauth.rst @@ -93,21 +93,21 @@ made based on other factors like group membership. :: - class: oauth_resource_server - class: oauth_resource_server_basic - oauth_rs_name: String, - oauth_rs_basic_secret: String, + class: oauth2_resource_server + class: oauth2_resource_server_basic + oauth2_rs_name: String, + oauth2_rs_basic_secret: String, # To validate the redirect root - oauth_rs_origin: String/URI + oauth2_rs_origin: String/URI # Scopes that apply to all users - oauth_rs_scope_implicit: String + oauth2_rs_scope_implicit: String # Scopes that map to groups which will be enforced. - oauth_rs_scope_map: (String, reference) + oauth2_rs_scope_map: (String, reference) # Filter of accounts that may authorise through this. - oauth_rs_account_filter: Filter + oauth2_rs_account_filter: Filter # A per-resource server fernet key for token/codes. # Allows reset per/application in case of suspect compromise. - oauth_rs_token_key: String + oauth2_rs_token_key: String The returned authorisation code should be fernet encrypted and contains the unsigned UAT content of the authorised user. diff --git a/kanidm_client/Cargo.toml b/kanidm_client/Cargo.toml index 273eee214..f7de34413 100644 --- a/kanidm_client/Cargo.toml +++ b/kanidm_client/Cargo.toml @@ -19,7 +19,7 @@ serde_json = "1.0" serde_derive = "1.0" toml = "0.5" uuid = { version = "0.8", features = ["serde", "v4"] } -url = "2.1.1" +url = { version = "2", features = ["serde"] } webauthn-rs = "0.3.0-alpha.7" tokio = { version = "1", features = ["rt", "net", "time", "macros", "sync", "signal"] } @@ -30,3 +30,5 @@ futures = "0.3" async-std = "1.6" webauthn-authenticator-rs = "0.3.0-alpha.9" +oauth2_ext = { package = "oauth2", version = "4.0", default-features = false } +base64 = "0.13" diff --git a/kanidm_client/src/asynchronous.rs b/kanidm_client/src/asynchronous.rs index ff1b3dd2f..d19065202 100644 --- a/kanidm_client/src/asynchronous.rs +++ b/kanidm_client/src/asynchronous.rs @@ -29,6 +29,10 @@ impl KanidmAsyncClient { self.origin.as_str() } + pub fn get_url(&self) -> &str { + self.addr.as_str() + } + pub async fn set_token(&self, new_token: String) { let mut tguard = self.bearer_token.write().await; *tguard = Some(new_token); @@ -55,10 +59,7 @@ impl KanidmAsyncClient { dest: &str, request: R, ) -> Result { - let dest = [self.addr.as_str(), dest].concat(); - debug!("{:?}", dest); - // format doesn't work in async ?! - // let dest = format!("{}{}", self.addr, dest); + let dest = format!("{}{}", self.get_url(), dest); let req_string = serde_json::to_string(&request).map_err(ClientError::JsonEncode)?; @@ -102,8 +103,9 @@ impl KanidmAsyncClient { let opid = headers .get(KOPID) - .and_then(|hv| hv.to_str().ok().map(str::to_string)) - .unwrap_or_else(|| "missing_kopid".to_string()); + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); debug!("opid -> {:?}", opid); match response.status() { @@ -128,10 +130,7 @@ impl KanidmAsyncClient { dest: &str, request: R, ) -> Result { - let dest = [self.addr.as_str(), dest].concat(); - debug!("{:?}", dest); - // format doesn't work in async ?! - // let dest = format!("{}{}", self.addr, dest); + let dest = format!("{}{}", self.get_url(), dest); let req_string = serde_json::to_string(&request).map_err(ClientError::JsonEncode)?; let response = self @@ -154,8 +153,9 @@ impl KanidmAsyncClient { let opid = response .headers() .get(KOPID) - .and_then(|hv| hv.to_str().ok().map(str::to_string)) - .unwrap_or_else(|| "missing_kopid".to_string()); + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); debug!("opid -> {:?}", opid); match response.status() { @@ -180,10 +180,7 @@ impl KanidmAsyncClient { dest: &str, request: R, ) -> Result { - let dest = [self.addr.as_str(), dest].concat(); - debug!("{:?}", dest); - // format doesn't work in async ?! - // let dest = format!("{}{}", self.addr, dest); + let dest = format!("{}{}", self.get_url(), dest); let req_string = serde_json::to_string(&request).map_err(ClientError::JsonEncode)?; @@ -209,8 +206,9 @@ impl KanidmAsyncClient { let opid = response .headers() .get(KOPID) - .and_then(|hv| hv.to_str().ok().map(str::to_string)) - .unwrap_or_else(|| "missing_kopid".to_string()); + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); debug!("opid -> {:?}", opid); @@ -231,10 +229,58 @@ impl KanidmAsyncClient { .map_err(|e| ClientError::JsonDecode(e, opid)) } + async fn perform_patch_request( + &self, + dest: &str, + request: R, + ) -> Result { + let dest = format!("{}{}", self.get_url(), dest); + + let req_string = serde_json::to_string(&request).map_err(ClientError::JsonEncode)?; + let response = self + .client + .patch(dest.as_str()) + .body(req_string) + .header(CONTENT_TYPE, APPLICATION_JSON); + + let response = { + let tguard = self.bearer_token.read().await; + if let Some(token) = &(*tguard) { + response.bearer_auth(token) + } else { + response + } + }; + + let response = response.send().await.map_err(ClientError::Transport)?; + + let opid = response + .headers() + .get(KOPID) + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); + debug!("opid -> {:?}", opid); + + match response.status() { + reqwest::StatusCode::OK => {} + unexpect => { + return Err(ClientError::Http( + unexpect, + response.json().await.ok(), + opid, + )) + } + } + + response + .json() + .await + .map_err(|e| ClientError::JsonDecode(e, opid)) + } + async fn perform_get_request(&self, dest: &str) -> Result { - let dest = [self.addr.as_str(), dest].concat(); - debug!("{:?}", dest); - // let dest = format!("{}{}", self.addr, dest); + let dest = format!("{}{}", self.get_url(), dest); let response = self.client.get(dest.as_str()); let response = { @@ -251,8 +297,9 @@ impl KanidmAsyncClient { let opid = response .headers() .get(KOPID) - .and_then(|hv| hv.to_str().ok().map(str::to_string)) - .unwrap_or_else(|| "missing_kopid".to_string()); + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); debug!("opid -> {:?}", opid); @@ -274,7 +321,7 @@ impl KanidmAsyncClient { } async fn perform_delete_request(&self, dest: &str) -> Result<(), ClientError> { - let dest = format!("{}{}", self.addr, dest); + let dest = format!("{}{}", self.get_url(), dest); let response = self .client @@ -295,8 +342,9 @@ impl KanidmAsyncClient { let opid = response .headers() .get(KOPID) - .and_then(|hv| hv.to_str().ok().map(str::to_string)) - .unwrap_or_else(|| "missing_kopid".to_string()); + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); debug!("opid -> {:?}", opid); match response.status() { @@ -321,7 +369,7 @@ impl KanidmAsyncClient { dest: &str, request: R, ) -> Result<(), ClientError> { - let dest = format!("{}{}", self.addr, dest); + let dest = format!("{}{}", self.get_url(), dest); let req_string = serde_json::to_string(&request).map_err(ClientError::JsonEncode)?; let response = self @@ -344,8 +392,9 @@ impl KanidmAsyncClient { let opid = response .headers() .get(KOPID) - .and_then(|hv| hv.to_str().ok().map(str::to_string)) - .unwrap_or_else(|| "missing_kopid".to_string()); + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); debug!("opid -> {:?}", opid); match response.status() { @@ -625,8 +674,9 @@ impl KanidmAsyncClient { let opid = response .headers() .get(KOPID) - .and_then(|hv| hv.to_str().ok().map(str::to_string)) - .unwrap_or_else(|| "missing_kopid".to_string()); + .and_then(|hv| hv.to_str().ok()) + .unwrap_or("missing_kopid") + .to_string(); debug!("opid -> {:?}", opid); match response.status() { @@ -1196,6 +1246,74 @@ impl KanidmAsyncClient { .await } + // ==== Oauth2 resource server configuration + pub async fn idm_oauth2_rs_list(&self) -> Result, ClientError> { + self.perform_get_request("/v1/oauth2").await + } + + pub async fn idm_oauth2_rs_basic_create( + &self, + name: &str, + origin: &str, + ) -> Result<(), ClientError> { + let mut new_oauth2_rs = Entry::default(); + new_oauth2_rs + .attrs + .insert("oauth2_rs_name".to_string(), vec![name.to_string()]); + new_oauth2_rs + .attrs + .insert("oauth2_rs_origin".to_string(), vec![origin.to_string()]); + self.perform_post_request("/v1/oauth2/_basic", new_oauth2_rs) + .await + } + + pub async fn idm_oauth2_rs_get(&self, id: &str) -> Result, ClientError> { + self.perform_get_request(format!("/v1/oauth2/{}", id).as_str()) + .await + } + + pub async fn idm_oauth2_rs_update( + &self, + id: &str, + name: Option<&str>, + origin: Option<&str>, + reset_secret: bool, + reset_token_key: bool, + ) -> Result<(), ClientError> { + let mut update_oauth2_rs = Entry { + attrs: BTreeMap::new(), + }; + + if let Some(newname) = name { + update_oauth2_rs + .attrs + .insert("oauth2_rs_name".to_string(), vec![newname.to_string()]); + } + if let Some(neworigin) = origin { + update_oauth2_rs + .attrs + .insert("oauth2_rs_origin".to_string(), vec![neworigin.to_string()]); + } + if reset_secret { + update_oauth2_rs + .attrs + .insert("oauth2_rs_basic_secret".to_string(), Vec::new()); + } + if reset_token_key { + update_oauth2_rs + .attrs + .insert("oauth2_rs_basic_token_key".to_string(), Vec::new()); + } + + self.perform_patch_request(format!("/v1/oauth2/{}", id).as_str(), update_oauth2_rs) + .await + } + + pub async fn idm_oauth2_rs_delete(&self, id: &str) -> Result<(), ClientError> { + self.perform_delete_request(["/v1/oauth2/", id].concat().as_str()) + .await + } + // ==== recycle bin pub async fn recycle_bin_list(&self) -> Result, ClientError> { self.perform_get_request("/v1/recycle_bin").await diff --git a/kanidm_client/src/lib.rs b/kanidm_client/src/lib.rs index cd33e3efd..300189fc0 100644 --- a/kanidm_client/src/lib.rs +++ b/kanidm_client/src/lib.rs @@ -368,6 +368,10 @@ impl KanidmClient { self.asclient.get_origin() } + pub fn get_url(&self) -> &str { + self.asclient.get_url() + } + pub fn new_session(&self) -> Result { // Copy our builder, and then just process it. self.asclient @@ -808,6 +812,41 @@ impl KanidmClient { tokio_block_on(self.asclient.idm_schema_classtype_get(id)) } + // ==== Oauth2 resource server configuration + + pub fn idm_oauth2_rs_list(&self) -> Result, ClientError> { + tokio_block_on(self.asclient.idm_oauth2_rs_list()) + } + + pub fn idm_oauth2_rs_basic_create(&self, name: &str, origin: &str) -> Result<(), ClientError> { + tokio_block_on(self.asclient.idm_oauth2_rs_basic_create(name, origin)) + } + + pub fn idm_oauth2_rs_get(&self, id: &str) -> Result, ClientError> { + tokio_block_on(self.asclient.idm_oauth2_rs_get(id)) + } + + pub fn idm_oauth2_rs_update( + &self, + id: &str, + name: Option<&str>, + origin: Option<&str>, + reset_secret: bool, + reset_token_key: bool, + ) -> Result<(), ClientError> { + tokio_block_on(self.asclient.idm_oauth2_rs_update( + id, + name, + origin, + reset_secret, + reset_token_key, + )) + } + + pub fn idm_oauth2_rs_delete(&self, id: &str) -> Result<(), ClientError> { + tokio_block_on(self.asclient.idm_oauth2_rs_delete(id)) + } + // ==== recycle bin pub fn recycle_bin_list(&self) -> Result, ClientError> { tokio_block_on(self.asclient.recycle_bin_list()) diff --git a/kanidm_client/tests/default_entries.rs b/kanidm_client/tests/default_entries.rs index 3840c2308..6dd200c88 100644 --- a/kanidm_client/tests/default_entries.rs +++ b/kanidm_client/tests/default_entries.rs @@ -319,7 +319,7 @@ fn test_default_entries_rbac_group_managers() { .map(|entry| entry.attrs.get("name").unwrap().first().unwrap()) .cloned() .collect(); - assert_eq!(default_group_names, group_names); + assert!(default_group_names.is_subset(&group_names)); test_modify_group(&rsclient, &DEFAULT_HP_GROUP_NAMES, false); test_modify_group(&rsclient, &DEFAULT_NOT_HP_GROUP_NAMES, true); @@ -423,7 +423,7 @@ fn test_default_entries_rbac_admins_schema_entries() { .collect(); println!("{:?}", classnames); - assert_eq!(default_classnames, classnames); + assert!(default_classnames.is_subset(&classnames)); let default_attributenames: HashSet = [ "acp_create_attr", @@ -486,7 +486,6 @@ fn test_default_entries_rbac_admins_schema_entries() { .cloned() .collect(); - // I wonder if this should be a subset op? assert!(default_attributenames.is_subset(&attributenames)); }); } diff --git a/kanidm_client/tests/oauth2_test.rs b/kanidm_client/tests/oauth2_test.rs new file mode 100644 index 000000000..64b6481c4 --- /dev/null +++ b/kanidm_client/tests/oauth2_test.rs @@ -0,0 +1,148 @@ +mod common; +use crate::common::{run_test, ADMIN_TEST_PASSWORD}; +use kanidm_client::KanidmClient; + +use kanidm_proto::oauth2::{AccessTokenRequest, AccessTokenResponse, ConsentRequest}; +use oauth2_ext::PkceCodeChallenge; +use std::collections::HashMap; +use url::Url; + +#[test] +fn test_oauth2_basic_flow() { + run_test(|rsclient: KanidmClient| { + let res = rsclient.auth_simple_password("admin", ADMIN_TEST_PASSWORD); + assert!(res.is_ok()); + + // Create an oauth2 application integration. + rsclient + .idm_oauth2_rs_basic_create("test_integration", "https://demo.example.com") + .expect("Failed to create oauth2 config"); + + let oauth2_config = rsclient + .idm_oauth2_rs_get("test_integration") + .ok() + .flatten() + .expect("Failed to retrieve test_integration config"); + + let client_secret = oauth2_config + .attrs + .get("oauth2_rs_basic_secret") + .map(|s| s[0].to_string()) + .expect("No basic secret present"); + + // Get our admin's auth token for our new client. + let admin_uat = rsclient.get_token().expect("No user auth token found"); + + let url = rsclient.get_url().to_string(); + + // We need a new reqwest client here. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to start tokio"); + rt.block_on(async { + // from here, we can now begin what would be a "interaction" to the oauth server. + // Create a new reqwest client - we'll be using this manually. + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("Failed to create client."); + // Step 1 - the Oauth2 Resource Server would send a redirect to the authorisation + // server, where the url contains a series of authorisation request parameters. + // + // Since we are a client, we can just "pretend" we got the redirect, and issue the + // get call directly. This should be a 200. (?) + + let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); + + let response = client + .get(format!("{}/oauth2/authorise", url)) + .bearer_auth(admin_uat.clone()) + .query(&[ + ("response_type", "code"), + ("client_id", "test_integration"), + ("state", "YWJjZGVm"), + ("code_challenge", pkce_code_challenge.as_str()), + ("code_challenge_method", "S256"), + ("redirect_uri", "https://demo.example.com/oauth2/flow"), + ("scope", "mail+name+test"), + ]) + .send() + .await + .expect("Failed to send request."); + + assert!(response.status() == reqwest::StatusCode::OK); + + let consent_req: ConsentRequest = response + .json() + .await + .expect("Failed to access response body"); + + // Step 2 - we now send the consent get to the server which yields a redirect with a + // state and code. + + let response = client + .get(format!("{}/oauth2/authorise/permit", url)) + .bearer_auth(admin_uat) + .query(&[("token", consent_req.consent_token.as_str())]) + .send() + .await + .expect("Failed to send request."); + + // This should yield a 302 redirect with some query params. + assert!(response.status() == reqwest::StatusCode::FOUND); + + // And we should have a URL in the location header. + let redir_str = response + .headers() + .get("Location") + .map(|hv| hv.to_str().ok().map(str::to_string)) + .flatten() + .expect("Invalid redirect url"); + + // Now check it's content + let redir_url = Url::parse(&redir_str).expect("Url parse failure"); + + // We should have state and code. + let pairs: HashMap<_, _> = redir_url.query_pairs().collect(); + + let code = pairs.get("code").expect("code not found!"); + + let state = pairs.get("state").expect("state not found!"); + + assert!(state == "YWJjZGVm"); + + // Step 3 - the "resource server" then uses this state and code to directly contact + // the authorisation server to request a token. + + let form_req = AccessTokenRequest { + grant_type: "authorization_code".to_string(), + code: code.to_string(), + redirect_uri: Url::parse("https://demo.example.com/oauth2/flow") + .expect("Invalid URL"), + client_id: None, + code_verifier: pkce_code_verifier.secret().clone(), + }; + + let response = client + .post(format!("{}/oauth2/token", url)) + .basic_auth("test_integration", Some(client_secret)) + .form(&form_req) + .send() + .await + .expect("Failed to send code exchange request."); + + assert!(response.status() == reqwest::StatusCode::OK); + + // The body is a json AccessTokenResponse + + let _atr = response + .json::() + .await + .expect("Unable to decode AccessTokenResponse"); + + // Step 4 - inspect the granted token. + }) + }) +} diff --git a/kanidm_client/tests/proto_v1_test.rs b/kanidm_client/tests/proto_v1_test.rs index 68daea7c0..4dbdcd677 100644 --- a/kanidm_client/tests/proto_v1_test.rs +++ b/kanidm_client/tests/proto_v1_test.rs @@ -83,7 +83,7 @@ fn test_server_whoami_anonymous() { None => panic!(), }; debug!("{}", uat); - assert!(uat.name == "anonymous"); + assert!(uat.spn == "anonymous@example.com"); }); } @@ -104,7 +104,7 @@ fn test_server_whoami_admin_simple_password() { None => panic!(), }; debug!("{}", uat); - assert!(uat.name == "admin"); + assert!(uat.spn == "admin@example.com"); }); } @@ -987,6 +987,80 @@ fn test_server_rest_webauthn_mfa_auth_lifecycle() { }); } +#[test] +fn test_server_rest_oauth2_basic_lifecycle() { + run_test(|rsclient: KanidmClient| { + let res = rsclient.auth_simple_password("admin", ADMIN_TEST_PASSWORD); + assert!(res.is_ok()); + + // List, there are non. + let initial_configs = rsclient + .idm_oauth2_rs_list() + .expect("Failed to retrieve oauth2 configs"); + + assert!(initial_configs.is_empty()); + + // Create a new oauth2 config + rsclient + .idm_oauth2_rs_basic_create("test_integration", "https://demo.example.com") + .expect("Failed to create oauth2 config"); + + // List, there is what we created. + let initial_configs = rsclient + .idm_oauth2_rs_list() + .expect("Failed to retrieve oauth2 configs"); + + assert!(initial_configs.len() == 1); + + // Get the value. Assert we have oauth2_rs_basic_secret, + // but can NOT see the token_secret. + let oauth2_config = rsclient + .idm_oauth2_rs_get("test_integration") + .ok() + .flatten() + .expect("Failed to retrieve test_integration config"); + + // What can we see? + assert!(oauth2_config.attrs.contains_key("oauth2_rs_basic_secret")); + // This is present, but redacted. + assert!(oauth2_config + .attrs + .contains_key("oauth2_rs_basic_token_key")); + + // Mod delete the secret/key and check them again. + // Check we can patch the oauth2_rs_name / oauth2_rs_origin + rsclient + .idm_oauth2_rs_update( + "test_integration", + None, + Some("https://new_demo.example.com"), + true, + true, + ) + .expect("Failed to update config"); + + let oauth2_config_updated = rsclient + .idm_oauth2_rs_get("test_integration") + .ok() + .flatten() + .expect("Failed to retrieve test_integration config"); + + assert!(oauth2_config_updated != oauth2_config); + + // Delete the config + rsclient + .idm_oauth2_rs_delete("test_integration") + .expect("Failed to delete test_integration"); + + // List, there are none. + let final_configs = rsclient + .idm_oauth2_rs_list() + .expect("Failed to retrieve oauth2 configs"); + + assert!(final_configs.is_empty()); + }); +} + // Test setting account expiry // Test the self version of the radius path. diff --git a/kanidm_proto/Cargo.toml b/kanidm_proto/Cargo.toml index a1ddf23d2..daad73079 100644 --- a/kanidm_proto/Cargo.toml +++ b/kanidm_proto/Cargo.toml @@ -17,6 +17,7 @@ uuid = { version = "0.8", features = ["serde", "wasm-bindgen"] } base32 = "0.4" webauthn-rs = { version = "0.3.0-alpha.7", default-features = false, features = ["wasm"] } time = { version = "0.2", features = ["serde", "std"] } +url = { version = "2", features = ["serde"] } [dev-dependencies] serde_json = "1.0" diff --git a/kanidm_proto/src/lib.rs b/kanidm_proto/src/lib.rs index 71232ccc0..fcd004c0c 100644 --- a/kanidm_proto/src/lib.rs +++ b/kanidm_proto/src/lib.rs @@ -11,4 +11,5 @@ #[macro_use] extern crate serde_derive; +pub mod oauth2; pub mod v1; diff --git a/kanidm_proto/src/oauth2.rs b/kanidm_proto/src/oauth2.rs new file mode 100644 index 000000000..2e5675b26 --- /dev/null +++ b/kanidm_proto/src/oauth2.rs @@ -0,0 +1,87 @@ +use url::Url; +use webauthn_rs::base64_data::Base64UrlSafeData; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub enum CodeChallengeMethod { + // default to plain if not requested as S256. Reject the auth? + // plain + // BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + S256, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AuthorisationRequest { + // Must be "code". (or token, see 4.2.1) + pub response_type: String, + pub client_id: String, + pub state: Base64UrlSafeData, + // base64? + pub code_challenge: Base64UrlSafeData, + // Probably also should be an enum. + pub code_challenge_method: CodeChallengeMethod, + // Uri? + pub redirect_uri: Url, + // appears to be + seperated? + pub scope: String, +} + +/// We ask our user to consent to this Authorisation Request with the +/// following data. +#[derive(Serialize, Deserialize, Debug)] +pub struct ConsentRequest { + // A pretty-name of the client + pub client_name: String, + pub scopes: Vec, + // The users displayname (?) + // pub display_name: String, + // The token we need to be given back to allow this to proceed + pub consent_token: String, +} + +// The resource server then contacts the token endpoint with +// +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessTokenRequest { + // must be authorization_code + pub grant_type: String, + // As sent by the authorisationCode + pub code: String, + // Must be the same as the original redirect uri. + pub redirect_uri: Url, + // REQUIRED, if the client is not authenticating with the + // authorization server as described in Section 3.2.1. + pub client_id: Option, + // + pub code_verifier: String, +} + +// We now check code_verifier is the same via the formula. + +// If and only if it checks out, we proceed. + +// Returned as a json body + +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessTokenResponse { + // Could be Base64UrlSafeData + pub access_token: String, + // Enum? + pub token_type: String, + // seconds. + pub expires_in: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + /// Space seperated list of scopes that were approved, if this differs from the + /// original request. + pub scope: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct ErrorResponse { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_uri: Option, +} diff --git a/kanidm_proto/src/v1.rs b/kanidm_proto/src/v1.rs index a8767076f..2f4a3f6f8 100644 --- a/kanidm_proto/src/v1.rs +++ b/kanidm_proto/src/v1.rs @@ -32,6 +32,7 @@ pub enum PluginError { Base(String), ReferentialIntegrity(String), PasswordImport(String), + Oauth2Secrets, } #[derive(Serialize, Deserialize, Debug, PartialEq)] @@ -179,19 +180,17 @@ pub enum AuthType { #[serde(rename_all = "lowercase")] pub struct UserAuthToken { pub session_id: Uuid, - // When this data should be considered invalid. Interpretation + pub auth_type: AuthType, + // When this token should be considered expired. Interpretation // may depend on the client application. pub expiry: time::OffsetDateTime, - pub name: String, - pub spn: String, - pub displayname: String, pub uuid: Uuid, - // #[serde(skip_serializing_if = "Option::is_none")] - // pub application: Option, - pub groups: Vec, - pub claims: Vec, - pub auth_type: AuthType, - // Should we allow supplemental ava's to be added on request? + // pub name: String, + pub spn: String, + // pub groups: Vec, + // pub claims: Vec, + // Should we just retrieve these inside the server instead of in the uat? + // or do we want per-session limit capabilities? pub lim_uidx: bool, pub lim_rmax: usize, pub lim_pmax: usize, @@ -200,17 +199,19 @@ pub struct UserAuthToken { impl fmt::Display for UserAuthToken { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "name: {}", self.name)?; + // writeln!(f, "name: {}", self.name)?; writeln!(f, "spn: {}", self.spn)?; - writeln!(f, "display: {}", self.displayname)?; writeln!(f, "uuid: {}", self.uuid)?; + /* + writeln!(f, "display: {}", self.displayname)?; for group in &self.groups { writeln!(f, "group: {:?}", group.name)?; } for claim in &self.claims { writeln!(f, "claim: {:?}", claim)?; } - writeln!(f, "expiry: {}", self.expiry) + */ + writeln!(f, "token expiry: {}", self.expiry) } } @@ -395,7 +396,7 @@ pub struct BackupCodesView { // the in memory server core entry type, without affecting the protoEntry type // -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] pub struct Entry { pub attrs: BTreeMap>, } diff --git a/kanidm_tools/src/cli/lib.rs b/kanidm_tools/src/cli/lib.rs index 0cbb0b79f..c5947416d 100644 --- a/kanidm_tools/src/cli/lib.rs +++ b/kanidm_tools/src/cli/lib.rs @@ -18,6 +18,7 @@ include!("../opt/kanidm.rs"); pub mod account; pub mod common; pub mod group; +pub mod oauth2; pub mod raw; pub mod recycle; pub mod session; @@ -71,6 +72,20 @@ impl SelfOpt { } } +impl SystemOpt { + pub fn debug(&self) -> bool { + match self { + SystemOpt::Oauth2(oopt) => oopt.debug(), + } + } + + pub fn exec(&self) { + match self { + SystemOpt::Oauth2(oopt) => oopt.exec(), + } + } +} + impl KanidmClientOpt { pub fn debug(&self) -> bool { match self { @@ -81,6 +96,7 @@ impl KanidmClientOpt { KanidmClientOpt::CSelf(csopt) => csopt.debug(), KanidmClientOpt::Account(aopt) => aopt.debug(), KanidmClientOpt::Group(gopt) => gopt.debug(), + KanidmClientOpt::System(sopt) => sopt.debug(), KanidmClientOpt::Recycle(ropt) => ropt.debug(), } } @@ -94,6 +110,7 @@ impl KanidmClientOpt { KanidmClientOpt::CSelf(csopt) => csopt.exec(), KanidmClientOpt::Account(aopt) => aopt.exec(), KanidmClientOpt::Group(gopt) => gopt.exec(), + KanidmClientOpt::System(sopt) => sopt.exec(), KanidmClientOpt::Recycle(ropt) => ropt.exec(), } } diff --git a/kanidm_tools/src/cli/oauth2.rs b/kanidm_tools/src/cli/oauth2.rs new file mode 100644 index 000000000..8f435d3be --- /dev/null +++ b/kanidm_tools/src/cli/oauth2.rs @@ -0,0 +1,48 @@ +use crate::Oauth2Opt; + +impl Oauth2Opt { + pub fn debug(&self) -> bool { + match self { + Oauth2Opt::List(copt) => copt.debug, + Oauth2Opt::Get(nopt) => nopt.copt.debug, + Oauth2Opt::CreateBasic(cbopt) => cbopt.nopt.copt.debug, + Oauth2Opt::Delete(nopt) => nopt.copt.debug, + } + } + + pub fn exec(&self) { + match self { + Oauth2Opt::List(copt) => { + let client = copt.to_client(); + match client.idm_oauth2_rs_list() { + Ok(r) => r.iter().for_each(|ent| println!("{}", ent)), + Err(e) => eprintln!("Error -> {:?}", e), + } + } + Oauth2Opt::Get(nopt) => { + let client = nopt.copt.to_client(); + match client.idm_oauth2_rs_get(nopt.name.as_str()) { + Ok(Some(e)) => println!("{}", e), + Ok(None) => println!("No matching entries"), + Err(e) => eprintln!("Error -> {:?}", e), + } + } + Oauth2Opt::CreateBasic(cbopt) => { + let client = cbopt.nopt.copt.to_client(); + match client + .idm_oauth2_rs_basic_create(cbopt.nopt.name.as_str(), cbopt.origin.as_str()) + { + Ok(_) => println!("Success"), + Err(e) => eprintln!("Error -> {:?}", e), + } + } + Oauth2Opt::Delete(nopt) => { + let client = nopt.copt.to_client(); + match client.idm_oauth2_rs_delete(nopt.name.as_str()) { + Ok(_) => println!("Success"), + Err(e) => eprintln!("Error -> {:?}", e), + } + } + } + } +} diff --git a/kanidm_tools/src/cli/session.rs b/kanidm_tools/src/cli/session.rs index ebcf32579..4708d509a 100644 --- a/kanidm_tools/src/cli/session.rs +++ b/kanidm_tools/src/cli/session.rs @@ -301,7 +301,7 @@ impl LoginOpt { }; // write them out. - if let Err(_) = write_tokens(&tokens) { + if write_tokens(&tokens).is_err() { error!("Error persisting authentication token store"); std::process::exit(1); }; diff --git a/kanidm_tools/src/opt/kanidm.rs b/kanidm_tools/src/opt/kanidm.rs index 2467b336c..b51c026f7 100644 --- a/kanidm_tools/src/opt/kanidm.rs +++ b/kanidm_tools/src/opt/kanidm.rs @@ -342,6 +342,40 @@ pub enum SelfOpt { SetPassword(CommonOpt), } +#[derive(Debug, StructOpt)] +pub struct Oauth2BasicCreateOpt { + #[structopt(name = "origin")] + origin: String, + #[structopt(flatten)] + nopt: Named, +} + +#[derive(Debug, StructOpt)] +pub enum Oauth2Opt { + #[structopt(name = "list")] + /// List all configured oauth2 resource servers + List(CommonOpt), + #[structopt(name = "get")] + /// Display a selected oauth2 resource server + Get(Named), + // #[structopt(name = "set")] + // /// Set options for a selected oauth2 resource server + // Set(), + #[structopt(name = "create")] + /// Create a new oauth2 resource server + CreateBasic(Oauth2BasicCreateOpt), + #[structopt(name = "delete")] + /// Delete a oauth2 resource server + Delete(Named), +} + +#[derive(Debug, StructOpt)] +pub enum SystemOpt { + #[structopt(name = "oauth2")] + /// Configure and display oauth2/oidc resource server configuration + Oauth2(Oauth2Opt), +} + #[derive(Debug, StructOpt)] #[structopt(about = "Kanidm Client Utility")] pub enum KanidmClientOpt { @@ -363,6 +397,9 @@ pub enum KanidmClientOpt { #[structopt(name = "group")] /// Group operations Group(GroupOpt), + #[structopt(name = "system")] + /// System configuration operations + System(SystemOpt), #[structopt(name = "recycle_bin")] /// Recycle Bin operations Recycle(RecycleOpt), diff --git a/kanidmd/Cargo.toml b/kanidmd/Cargo.toml index 629dc3485..ce6327b63 100644 --- a/kanidmd/Cargo.toml +++ b/kanidmd/Cargo.toml @@ -22,12 +22,12 @@ kanidm_proto = { path = "../kanidm_proto", version = "1.1.0-alpha" } jemallocator = { version = "0.3.0", optional = true } -url = "2.1" +url = { version = "2", features = ["serde"] } tide = "0.16" tide-rustls = "0.3" async-trait = "0.1" async-h1 = "2.0" -fernet = "^0.1.4" +fernet = { version = "^0.1.4", features = ["fernet_danger_timestamps"] } bundy = "^0.1.1" async-std = "1.6" diff --git a/kanidmd/server.toml b/kanidmd/server.toml index f0b1000b8..f02cd039b 100644 --- a/kanidmd/server.toml +++ b/kanidmd/server.toml @@ -6,5 +6,5 @@ tls_chain = "../insecure/chain.pem" tls_key = "../insecure/key.pem" # log_level = "perfbasic" # log_level = "quiet" -log_level = "verbose" +# log_level = "verbose" origin = "https://idm.example.com:8443" diff --git a/kanidmd/src/lib/actors/v1_read.rs b/kanidmd/src/lib/actors/v1_read.rs index 97ce8ea3d..3e8606efa 100644 --- a/kanidmd/src/lib/actors/v1_read.rs +++ b/kanidmd/src/lib/actors/v1_read.rs @@ -13,6 +13,10 @@ use crate::value::PartialValue; use kanidm_proto::v1::{BackupCodesView, OperationError, RadiusAuthToken}; use crate::filter::{Filter, FilterInvalid}; +use crate::idm::oauth2::{ + AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, + ConsentRequest, Oauth2Error, +}; use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::ldap::{LdapBoundToken, LdapResponseState, LdapServer}; @@ -392,9 +396,10 @@ impl QueryServerReadV1 { let r = entries .pop() // From the entry, turn it into the value - .and_then(|e| { - e.get_ava_single("radius_secret") - .and_then(|v| v.get_radius_secret().map(|s| s.to_string())) + .and_then(|entry| { + entry + .get_ava_single("radius_secret") + .and_then(|v| v.get_secret_str().map(str::to_string)) }); Ok(r) } @@ -898,6 +903,112 @@ impl QueryServerReadV1 { res } + pub async fn handle_oauth2_authorise( + &self, + uat: Option, + auth_req: AuthorisationRequest, + eventid: Uuid, + ) -> Result { + let mut audit = AuditScope::new("oauth2_authorise", eventid, self.log_level); + let ct = duration_from_epoch_now(); + let idms_prox_read = self.idms.proxy_read_async().await; + let res = lperf_op_segment!( + &mut audit, + "actors::v1_read::handle", + || { + let (ident, uat) = idms_prox_read + .validate_and_parse_uat(&mut audit, uat.as_deref(), ct) + .and_then(|uat| { + idms_prox_read + .process_uat_to_identity(&mut audit, &uat, ct) + .map(|ident| (ident, uat)) + }) + .map_err(|e| { + ladmin_error!(audit, "Invalid identity: {:?}", e); + Oauth2Error::AuthenticationRequired + })?; + + // Now we can send to the idm server for authorisation checking. + idms_prox_read.check_oauth2_authorisation(&mut audit, &ident, &uat, &auth_req, ct) + } + ); + self.log.send(audit).map_err(|_| { + error!("CRITICAL: UNABLE TO COMMIT LOGS"); + Oauth2Error::ServerError(OperationError::InvalidState) + })?; + res + } + + pub async fn handle_oauth2_authorise_permit( + &self, + uat: Option, + consent_req: String, + eventid: Uuid, + ) -> Result { + let mut audit = AuditScope::new("oauth2_authorise_permit", eventid, self.log_level); + let ct = duration_from_epoch_now(); + let idms_prox_read = self.idms.proxy_read_async().await; + let res = lperf_op_segment!( + &mut audit, + "actors::v1_read::handle", + || { + let (ident, uat) = idms_prox_read + .validate_and_parse_uat(&mut audit, uat.as_deref(), ct) + .and_then(|uat| { + idms_prox_read + .process_uat_to_identity(&mut audit, &uat, ct) + .map(|ident| (ident, uat)) + }) + .map_err(|e| { + ladmin_error!(audit, "Invalid identity: {:?}", e); + e + })?; + + idms_prox_read.check_oauth2_authorise_permit( + &mut audit, + &ident, + &uat, + &consent_req, + ct, + ) + } + ); + self.log.send(audit).map_err(|_| { + error!("CRITICAL: UNABLE TO COMMIT LOGS"); + OperationError::InvalidState + })?; + res + } + + pub async fn handle_oauth2_token_exchange( + &self, + client_authz: String, + token_req: AccessTokenRequest, + eventid: Uuid, + ) -> Result { + let mut audit = AuditScope::new("oauth2_token_exchange", eventid, self.log_level); + let ct = duration_from_epoch_now(); + let idms_prox_read = self.idms.proxy_read_async().await; + let res = lperf_op_segment!( + &mut audit, + "actors::v1_read::handle", + || { + // Now we can send to the idm server for authorisation checking. + idms_prox_read.check_oauth2_token_exchange( + &mut audit, + &client_authz, + &token_req, + ct, + ) + } + ); + self.log.send(audit).map_err(|_| { + error!("CRITICAL: UNABLE TO COMMIT LOGS"); + Oauth2Error::ServerError(OperationError::InvalidState) + })?; + res + } + pub async fn handle_ldaprequest( &self, eventid: Uuid, diff --git a/kanidmd/src/lib/actors/v1_write.rs b/kanidmd/src/lib/actors/v1_write.rs index c700f97f4..5fa923697 100644 --- a/kanidmd/src/lib/actors/v1_write.rs +++ b/kanidmd/src/lib/actors/v1_write.rs @@ -24,6 +24,7 @@ use crate::idm::delayed::DelayedAction; use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::utils::duration_from_epoch_now; +use kanidm_proto::v1::Entry as ProtoEntry; use kanidm_proto::v1::Modify as ProtoModify; use kanidm_proto::v1::ModifyList as ProtoModifyList; use kanidm_proto::v1::{ @@ -142,12 +143,15 @@ impl QueryServerWriteV1 { e })?; + let f_uuid = filter_all!(f_eq("uuid", PartialValue::new_uuid(target_uuid))); + // Add any supplemental conditions we have. + let joined_filter = Filter::join_parts_and(f_uuid, filter); + let mdf = match ModifyEvent::from_internal_parts( audit, ident, - target_uuid, ml, - filter, + &joined_filter, &idms_prox_write.qs_write, ) { Ok(m) => m, @@ -314,6 +318,64 @@ impl QueryServerWriteV1 { res } + pub async fn handle_internalpatch( + &self, + uat: Option, + filter: Filter, + update: ProtoEntry, + eventid: Uuid, + ) -> Result<(), OperationError> { + // Given a protoEntry, turn this into a modification set. + let mut audit = AuditScope::new("internal_patch", eventid, self.log_level); + let idms_prox_write = self.idms.proxy_write_async(duration_from_epoch_now()).await; + let res = lperf_segment!( + &mut audit, + "actors::v1_write::handle", + || { + let ct = duration_from_epoch_now(); + let ident = idms_prox_write + .validate_and_parse_uat(&mut audit, uat.as_deref(), ct) + .and_then(|uat| idms_prox_write.process_uat_to_identity(&mut audit, &uat, ct)) + .map_err(|e| { + ladmin_error!(&mut audit, "Invalid identity: {:?}", e); + e + })?; + + // Transform the ProtoEntry to a Modlist + let modlist = + ModifyList::from_patch(&mut audit, &update, &idms_prox_write.qs_write) + .map_err(|e| { + ladmin_error!(&mut audit, "Invalid Patch Request: {:?}", e); + e + })?; + + let mdf = ModifyEvent::from_internal_parts( + &mut audit, + ident, + &modlist, + &filter, + &idms_prox_write.qs_write, + ) + .map_err(|e| { + ladmin_error!(audit, "Failed to begin modify: {:?}", e); + e + })?; + + ltrace!(audit, "Begin modify event {:?}", mdf); + + idms_prox_write + .qs_write + .modify(&mut audit, &mdf) + .and_then(|_| idms_prox_write.commit(&mut audit)) + } + ); + self.log.send(audit).map_err(|_| { + error!("CRITICAL: UNABLE TO COMMIT LOGS"); + OperationError::InvalidState + })?; + res.map(|_| ()) + } + pub async fn handle_internaldelete( &self, uat: Option, diff --git a/kanidmd/src/lib/be/dbvalue.rs b/kanidmd/src/lib/be/dbvalue.rs index ad2580684..42572730d 100644 --- a/kanidmd/src/lib/be/dbvalue.rs +++ b/kanidmd/src/lib/be/dbvalue.rs @@ -1,4 +1,5 @@ use std::{collections::HashSet, time::Duration}; +use url::Url; use uuid::Uuid; use webauthn_rs::proto::COSEKey; @@ -146,6 +147,8 @@ pub enum DbValueV1 { DateTime(String), #[serde(rename = "EM")] EmailAddress(DbValueEmailAddressV1), + #[serde(rename = "UR")] + Url(Url), } #[cfg(test)] diff --git a/kanidmd/src/lib/constants/acp.rs b/kanidmd/src/lib/constants/acp.rs index 482c095d1..f301b1940 100644 --- a/kanidmd/src/lib/constants/acp.rs +++ b/kanidmd/src/lib/constants/acp.rs @@ -988,3 +988,58 @@ pub const JSON_IDM_HP_ACP_GROUP_UNIX_EXTEND_PRIV_V1: &str = r#"{ "acp_modify_class": ["posixgroup"] } }"#; + +// 35 oauth2 manage +pub const JSON_IDM_HP_ACP_OAUTH2_MANAGE_PRIV_V1: &str = r#"{ + "attrs": { + "class": [ + "object", + "access_control_profile", + "access_control_search", + "access_control_modify", + "access_control_delete", + "access_control_create" + ], + "name": ["idm_acp_hp_oauth2_manage_priv"], + "uuid": ["00000000-0000-0000-0000-ffffff000035"], + "description": ["Builtin IDM Control for managing oauth2 resource server integrations."], + "acp_receiver": [ + "{\"eq\":[\"memberof\",\"00000000-0000-0000-0000-000000000027\"]}" + ], + "acp_targetscope": [ + "{\"and\": [{\"eq\": [\"class\",\"oauth2_resource_server\"]},{\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}" + ], + "acp_search_attr": [ + "class", + "description", + "oauth2_rs_name", + "oauth2_rs_origin", + "oauth2_rs_account_filter", + "oauth2_rs_basic_secret", + "oauth2_rs_basic_token_key" + ], + "acp_modify_removedattr": [ + "description", + "oauth2_rs_name", + "oauth2_rs_origin", + "oauth2_rs_account_filter", + "oauth2_rs_basic_secret", + "oauth2_rs_basic_token_key" + ], + "acp_modify_presentattr": [ + "description", + "oauth2_rs_name", + "oauth2_rs_origin", + "oauth2_rs_account_filter" + ], + "acp_modify_class": [], + "acp_create_attr": [ + "class", + "description", + "oauth2_rs_name", + "oauth2_rs_origin", + "oauth2_rs_account_filter" + ], + "acp_create_class": ["oauth2_resource_server", "oauth2_resource_server_basic", "object"] + } +}"#; diff --git a/kanidmd/src/lib/constants/entries.rs b/kanidmd/src/lib/constants/entries.rs index 87e1d2e1c..d2d6c5f96 100644 --- a/kanidmd/src/lib/constants/entries.rs +++ b/kanidmd/src/lib/constants/entries.rs @@ -292,6 +292,17 @@ pub const JSON_DOMAIN_ADMINS: &str = r#"{ ] } }"#; +pub const JSON_IDM_HP_OAUTH2_MANAGE_PRIV_V1: &str = r#"{ + "attrs": { + "class": ["group", "object"], + "name": ["idm_hp_oauth2_manage_priv"], + "uuid": ["00000000-0000-0000-0000-000000000027"], + "description": ["Builtin IDM Group for managing oauth2 resource server integrations to this authentication domain."], + "member": [ + "00000000-0000-0000-0000-000000000019" + ] + } +}"#; // This must be the last group to init to include the UUID of the other high priv groups. pub const JSON_IDM_HIGH_PRIVILEGE_V1: &str = r#"{ @@ -324,6 +335,7 @@ pub const JSON_IDM_HIGH_PRIVILEGE_V1: &str = r#"{ "00000000-0000-0000-0000-000000000024", "00000000-0000-0000-0000-000000000025", "00000000-0000-0000-0000-000000000026", + "00000000-0000-0000-0000-000000000027", "00000000-0000-0000-0000-000000001000" ] } diff --git a/kanidmd/src/lib/constants/mod.rs b/kanidmd/src/lib/constants/mod.rs index 73bbd510b..9d07bd2ba 100644 --- a/kanidmd/src/lib/constants/mod.rs +++ b/kanidmd/src/lib/constants/mod.rs @@ -13,7 +13,7 @@ pub use crate::constants::system_config::*; pub use crate::constants::uuids::*; // Increment this as we add new schema types and values!!! -pub const SYSTEM_INDEX_VERSION: i64 = 17; +pub const SYSTEM_INDEX_VERSION: i64 = 18; // On test builds, define to 60 seconds #[cfg(test)] pub const PURGE_FREQUENCY: u64 = 60; diff --git a/kanidmd/src/lib/constants/schema.rs b/kanidmd/src/lib/constants/schema.rs index 42924ea5e..0946c54f4 100644 --- a/kanidmd/src/lib/constants/schema.rs +++ b/kanidmd/src/lib/constants/schema.rs @@ -175,7 +175,7 @@ pub const JSON_SCHEMA_ATTR_RADIUS_SECRET: &str = r#"{ "radius_secret" ], "syntax": [ - "RADIUS_UTF8STRING" + "SECRET_UTF8STRING" ], "uuid": [ "00000000-0000-0000-0000-ffff00000051" @@ -484,6 +484,153 @@ pub const JSON_SCHEMA_ATTR_ACCOUNT_VALID_FROM: &str = r#"{ } }"#; +pub const JSON_SCHEMA_ATTR_OAUTH2_RS_NAME: &str = r#"{ + "attrs": { + "class": [ + "object", + "system", + "attributetype" + ], + "description": [ + "The unique name of an external Oauth2 resource" + ], + "index": [ + "EQUALITY" + ], + "unique": [ + "true" + ], + "multivalue": [ + "false" + ], + "attributename": [ + "oauth2_rs_name" + ], + "syntax": [ + "UTF8STRING_INAME" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000080" + ] + } +}"#; + +pub const JSON_SCHEMA_ATTR_OAUTH2_RS_ORIGIN: &str = r#"{ + "attrs": { + "class": [ + "object", + "system", + "attributetype" + ], + "description": [ + "The origin domain of an oauth2 resource server" + ], + "index": [], + "unique": [ + "false" + ], + "multivalue": [ + "false" + ], + "attributename": [ + "oauth2_rs_origin" + ], + "syntax": [ + "URL" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000081" + ] + } +}"#; + +pub const JSON_SCHEMA_ATTR_OAUTH2_RS_ACCOUNT_FILTER: &str = r#"{ + "attrs": { + "class": [ + "object", + "system", + "attributetype" + ], + "description": [ + "A filter describing who may access the associated oauth2 resource server" + ], + "index": [], + "unique": [ + "false" + ], + "multivalue": [ + "false" + ], + "attributename": [ + "oauth2_rs_account_filter" + ], + "syntax": [ + "JSON_FILTER" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000082" + ] + } +}"#; + +pub const JSON_SCHEMA_ATTR_OAUTH2_RS_BASIC_SECRET: &str = r#"{ + "attrs": { + "class": [ + "object", + "system", + "attributetype" + ], + "description": [ + "When using oauth2 basic authentication, the secret string of the resource server" + ], + "index": [], + "unique": [ + "false" + ], + "multivalue": [ + "false" + ], + "attributename": [ + "oauth2_rs_basic_secret" + ], + "syntax": [ + "UTF8STRING" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000083" + ] + } +}"#; + +pub const JSON_SCHEMA_ATTR_OAUTH2_RS_BASIC_TOKEN_KEY: &str = r#"{ + "attrs": { + "class": [ + "object", + "system", + "attributetype" + ], + "description": [ + "An oauth2 basic resource servers unique token signing key" + ], + "index": [], + "unique": [ + "false" + ], + "multivalue": [ + "false" + ], + "attributename": [ + "oauth2_rs_basic_token_key" + ], + "syntax": [ + "SECRET_UTF8STRING" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000084" + ] + } +}"#; + // === classes === pub const JSON_SCHEMA_CLASS_PERSON: &str = r#" @@ -686,3 +833,58 @@ pub const JSON_SCHEMA_CLASS_SYSTEM_CONFIG: &str = r#" } } "#; + +pub const JSON_SCHEMA_CLASS_OAUTH2_RS: &str = r#" + { + "attrs": { + "class": [ + "object", + "system", + "classtype" + ], + "description": [ + "The class representing a configured Oauth2 Resource Server" + ], + "classname": [ + "oauth2_resource_server" + ], + "systemmay": [ + "description", + "oauth2_rs_account_filter" + ], + "systemmust": [ + "oauth2_rs_name", + "oauth2_rs_origin" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000085" + ] + } + } +"#; + +pub const JSON_SCHEMA_CLASS_OAUTH2_RS_BASIC: &str = r#" + { + "attrs": { + "class": [ + "object", + "system", + "classtype" + ], + "description": [ + "The class representing a configured Oauth2 Resource Server" + ], + "classname": [ + "oauth2_resource_server_basic" + ], + "systemmay": [], + "systemmust": [ + "oauth2_rs_basic_secret", + "oauth2_rs_basic_token_key" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000086" + ] + } + } +"#; diff --git a/kanidmd/src/lib/constants/uuids.rs b/kanidmd/src/lib/constants/uuids.rs index f5569223c..c0cf40b3d 100644 --- a/kanidmd/src/lib/constants/uuids.rs +++ b/kanidmd/src/lib/constants/uuids.rs @@ -21,7 +21,7 @@ pub const _STR_UUID_IDM_ACCOUNT_MANAGE_PRIV: &str = "00000000-0000-0000-0000-000 pub const _STR_UUID_IDM_GROUP_MANAGE_PRIV: &str = "00000000-0000-0000-0000-000000000015"; pub const _STR_UUID_IDM_HP_ACCOUNT_MANAGE_PRIV: &str = "00000000-0000-0000-0000-000000000016"; pub const _STR_UUID_IDM_HP_GROUP_MANAGE_PRIV: &str = "00000000-0000-0000-0000-000000000017"; -pub const _STR_UUID_IDM_ADMIN_V1: &str = "00000000-0000-0000-0000-000000000018"; +pub const STR_UUID_IDM_ADMIN_V1: &str = "00000000-0000-0000-0000-000000000018"; pub const _STR_UUID_SYSTEM_ADMINS: &str = "00000000-0000-0000-0000-000000000019"; pub const STR_UUID_DOMAIN_ADMINS: &str = "00000000-0000-0000-0000-000000000020"; pub const _STR_UUID_IDM_ACCOUNT_UNIX_EXTEND_PRIV: &str = "00000000-0000-0000-0000-000000000021"; @@ -33,6 +33,8 @@ pub const _STR_UUID_IDM_PEOPLE_EXTEND_PRIV: &str = "00000000-0000-0000-0000-0000 pub const _STR_UUID_IDM_HP_ACCOUNT_UNIX_EXTEND_PRIV: &str = "00000000-0000-0000-0000-000000000025"; pub const _STR_UUID_IDM_HP_GROUP_UNIX_EXTEND_PRIV: &str = "00000000-0000-0000-0000-000000000026"; +pub const _STR_UUID_IDM_HP_OAUTH2_MANAGE_PRIV: &str = "00000000-0000-0000-0000-000000000027"; + // pub const _STR_UUID_IDM_HIGH_PRIVILEGE: &str = "00000000-0000-0000-0000-000000001000"; @@ -130,6 +132,18 @@ pub const STR_UUID_SCHEMA_ATTR_KEYS: &str = "00000000-0000-0000-0000-ffff0000007 pub const STR_UUID_SCHEMA_ATTR_SSHPUBLICKEY: &str = "00000000-0000-0000-0000-ffff00000078"; pub const STR_UUID_SCHEMA_ATTR_CN: &str = "00000000-0000-0000-0000-ffff00000078"; pub const STR_UUID_SCHEMA_ATTR_UIDNUMBER: &str = "00000000-0000-0000-0000-ffff00000079"; + +pub const _STR_UUID_SCHEMA_ATTR_OAUTH2_RS_NAME: &str = "00000000-0000-0000-0000-ffff00000080"; +pub const _STR_UUID_SCHEMA_ATTR_OAUTH2_RS_ORIGIN: &str = "00000000-0000-0000-0000-ffff00000081"; +pub const _STR_UUID_SCHEMA_ATTR_OAUTH2_RS_ACCOUNT_FILTER: &str = + "00000000-0000-0000-0000-ffff00000082"; +pub const _STR_UUID_SCHEMA_ATTR_OAUTH2_RS_BASIC_SECRET: &str = + "00000000-0000-0000-0000-ffff00000083"; +pub const _STR_UUID_SCHEMA_ATTR_OAUTH2_RS_BASIC_TOKEN_KEY: &str = + "00000000-0000-0000-0000-ffff00000084"; +pub const STR_UUID_SCHEMA_CLASS_OAUTH2_RS: &str = "00000000-0000-0000-0000-ffff00000085"; +pub const STR_UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC: &str = "00000000-0000-0000-0000-ffff00000086"; + // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. pub const STR_UUID_SYSTEM_INFO: &str = "00000000-0000-0000-0000-ffffff000001"; @@ -178,6 +192,7 @@ pub const _STR_UUID_IDM_HP_ACP_ACCOUNT_UNIX_EXTEND_PRIV_V1: &str = "00000000-0000-0000-0000-ffffff000033"; pub const _STR_UUID_IDM_HP_ACP_GROUP_UNIX_EXTEND_PRIV_V1: &str = "00000000-0000-0000-0000-ffffff000034"; +pub const _STR_UUID_IDM_HP_ACP_OAUTH2_MANAGE_PRIV_V1: &str = "00000000-0000-0000-0000-ffffff000035"; // End of system ranges pub const STR_UUID_DOES_NOT_EXIST: &str = "00000000-0000-0000-0000-fffffffffffe"; @@ -185,6 +200,7 @@ pub const STR_UUID_ANONYMOUS: &str = "00000000-0000-0000-0000-ffffffffffff"; lazy_static! { pub static ref UUID_ADMIN: Uuid = Uuid::parse_str(STR_UUID_ADMIN).unwrap(); + pub static ref UUID_IDM_ADMIN: Uuid = Uuid::parse_str(STR_UUID_IDM_ADMIN_V1).unwrap(); pub static ref UUID_DOES_NOT_EXIST: Uuid = Uuid::parse_str(STR_UUID_DOES_NOT_EXIST).unwrap(); pub static ref UUID_ANONYMOUS: Uuid = Uuid::parse_str(STR_UUID_ANONYMOUS).unwrap(); pub static ref UUID_SYSTEM_CONFIG: Uuid = Uuid::parse_str(STR_UUID_SYSTEM_CONFIG).unwrap(); @@ -300,4 +316,8 @@ lazy_static! { pub static ref UUID_SCHEMA_ATTR_CN: Uuid = Uuid::parse_str(STR_UUID_SCHEMA_ATTR_CN).unwrap(); pub static ref UUID_SCHEMA_ATTR_UIDNUMBER: Uuid = Uuid::parse_str(STR_UUID_SCHEMA_ATTR_UIDNUMBER).unwrap(); + pub static ref UUID_SCHEMA_CLASS_OAUTH2_RS: Uuid = + Uuid::parse_str(STR_UUID_SCHEMA_CLASS_OAUTH2_RS).unwrap(); + pub static ref UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC: Uuid = + Uuid::parse_str(STR_UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC).unwrap(); } diff --git a/kanidmd/src/lib/core/https.rs b/kanidmd/src/lib/core/https/mod.rs similarity index 93% rename from kanidmd/src/lib/core/https.rs rename to kanidmd/src/lib/core/https/mod.rs index 41e707284..e2ba297c5 100644 --- a/kanidmd/src/lib/core/https.rs +++ b/kanidmd/src/lib/core/https/mod.rs @@ -1,11 +1,15 @@ +mod oauth2; + +use self::oauth2::*; + use crate::actors::v1_read::QueryServerReadV1; use crate::actors::v1_write::QueryServerWriteV1; use crate::config::{ServerRole, TlsConfiguration}; use crate::event::AuthResult; use crate::filter::{Filter, FilterInvalid}; use crate::idm::AuthState; +use crate::prelude::*; use crate::status::{StatusActor, StatusRequestEvent}; -use crate::value::PartialValue; use kanidm_proto::v1::Entry as ProtoEntry; use kanidm_proto::v1::{ @@ -42,6 +46,8 @@ pub trait RequestExtensions { fn get_current_auth_session_id(&self) -> Option; fn get_url_param(&self, param: &str) -> Result; + + fn new_eventid(&self) -> (Uuid, String); } impl RequestExtensions for tide::Request { @@ -59,6 +65,7 @@ impl RequestExtensions for tide::Request { h.as_str().strip_prefix("Bearer ") }) .map(|s| s.to_string()) + .or_else(|| self.session().get::("bearer")) /* .and_then(|ts| { // Take the token str and attempt to decrypt @@ -91,9 +98,15 @@ impl RequestExtensions for tide::Request { fn get_url_param(&self, param: &str) -> Result { self.param(param) - .map(|s| s.to_string()) + .map(str::to_string) .map_err(|_| tide::Error::from_str(tide::StatusCode::ImATeapot, "teapot")) } + + fn new_eventid(&self) -> (Uuid, String) { + let eventid = Uuid::new_v4(); + let hv = eventid.to_hyphenated().to_string(); + (eventid, hv) + } } pub fn to_tide_response( @@ -135,14 +148,6 @@ pub fn to_tide_response( }) } -macro_rules! new_eventid { - () => {{ - let eventid = Uuid::new_v4(); - let hv = eventid.to_hyphenated().to_string(); - (eventid, hv) - }}; -} - // Handle the various end points we need to expose async fn index_view(_req: tide::Request) -> tide::Result { let mut res = tide::Response::new(200); @@ -177,7 +182,7 @@ pub async fn create(mut req: tide::Request) -> tide::Result { // parse the req to a CreateRequest let msg: CreateRequest = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req.state().qe_w_ref.handle_create(uat, msg, eventid).await; to_tide_response(res, hvalue) @@ -186,7 +191,7 @@ pub async fn create(mut req: tide::Request) -> tide::Result { pub async fn modify(mut req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let msg: ModifyRequest = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req.state().qe_w_ref.handle_modify(uat, msg, eventid).await; to_tide_response(res, hvalue) } @@ -194,7 +199,7 @@ pub async fn modify(mut req: tide::Request) -> tide::Result { pub async fn delete(mut req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let msg: DeleteRequest = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req.state().qe_w_ref.handle_delete(uat, msg, eventid).await; to_tide_response(res, hvalue) } @@ -202,14 +207,14 @@ pub async fn delete(mut req: tide::Request) -> tide::Result { pub async fn search(mut req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let msg: SearchRequest = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req.state().qe_r_ref.handle_search(uat, msg, eventid).await; to_tide_response(res, hvalue) } pub async fn whoami(req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); // New event, feed current auth data from the token to it. let res = req.state().qe_r_ref.handle_whoami(uat, eventid).await; to_tide_response(res, hvalue) @@ -224,7 +229,7 @@ pub async fn json_rest_event_get( ) -> tide::Result { let uat = req.get_current_uat(); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -244,7 +249,7 @@ async fn json_rest_event_get_id( let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str()))); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -263,7 +268,7 @@ async fn json_rest_event_delete_id( let id = req.get_url_param("id")?; let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str()))); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -282,7 +287,7 @@ async fn json_rest_event_get_id_attr( let uat = req.get_current_uat(); let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str()))); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let attrs = Some(vec![attr.clone()]); @@ -300,7 +305,7 @@ async fn json_rest_event_post( classes: Vec, ) -> tide::Result { debug_assert!(!classes.is_empty()); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); // Read the json from the wire. let uat = req.get_current_uat(); let mut obj: ProtoEntry = req.body_json().await?; @@ -319,7 +324,7 @@ async fn json_rest_event_post_id_attr( let uuid_or_name = req.get_url_param("id")?; let attr = req.get_url_param("attr")?; let values: Vec = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -337,7 +342,7 @@ async fn json_rest_event_put_id_attr( let attr = req.get_url_param("attr")?; let values: Vec = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -354,7 +359,7 @@ async fn json_rest_event_delete_id_attr( ) -> tide::Result { let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); // TODO #211: Attempt to get an option Vec here? // It's probably better to focus on SCIM instead, it seems richer than this. @@ -388,7 +393,7 @@ async fn json_rest_event_credential_put(mut req: tide::Request) -> tid let uuid_or_name = req.get_url_param("id")?; let sac: SetCredentialRequest = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -435,7 +440,7 @@ pub async fn schema_attributetype_get_id(req: tide::Request) -> tide:: f_eq("attributename", PartialValue::new_iutf8(id.as_str())) ])); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -461,7 +466,7 @@ pub async fn schema_classtype_get_id(req: tide::Request) -> tide::Resu f_eq("classname", PartialValue::new_iutf8(id.as_str())) ])); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -540,7 +545,7 @@ pub async fn account_get_id_credential_status(req: tide::Request) -> t let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -554,7 +559,7 @@ pub async fn account_get_backup_code(req: tide::Request) -> tide::Resu let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -569,7 +574,7 @@ pub async fn account_get_id_ssh_pubkeys(req: tide::Request) -> tide::R let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -585,7 +590,7 @@ pub async fn account_post_id_ssh_pubkey(mut req: tide::Request) -> tid let (tag, key): (String, String) = req.body_json().await?; let filter = filter_all!(f_eq("class", PartialValue::new_class("account"))); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); // Add a msg here let res = req .state() @@ -600,7 +605,7 @@ pub async fn account_get_id_ssh_pubkey_tag(req: tide::Request) -> tide let uuid_or_name = req.get_url_param("id")?; let tag = req.get_url_param("tag")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -618,7 +623,7 @@ pub async fn account_delete_id_ssh_pubkey_tag(req: tide::Request) -> t let values = vec![tag]; let filter = filter_all!(f_eq("class", PartialValue::new_class("account"))); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -633,7 +638,7 @@ pub async fn account_get_id_radius(req: tide::Request) -> tide::Result let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -648,7 +653,7 @@ pub async fn account_post_id_radius_regenerate(req: tide::Request) -> let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -668,7 +673,7 @@ pub async fn account_get_id_radius_token(req: tide::Request) -> tide:: let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -681,7 +686,7 @@ pub async fn account_get_id_radius_token(req: tide::Request) -> tide:: pub async fn account_post_id_person_extend(req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -694,7 +699,7 @@ pub async fn account_post_id_unix(mut req: tide::Request) -> tide::Res let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; let obj: AccountUnixExtend = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -707,7 +712,7 @@ pub async fn account_get_id_unix_token(req: tide::Request) -> tide::Re let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -722,7 +727,7 @@ pub async fn account_post_id_unix_auth(mut req: tide::Request) -> tide let uuid_or_name = req.get_url_param("id")?; let obj: SingleStringRequest = req.body_json().await?; let cred = obj.value; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_r_ref @@ -736,7 +741,7 @@ pub async fn account_put_id_unix_credential(mut req: tide::Request) -> let uuid_or_name = req.get_url_param("id")?; let obj: SingleStringRequest = req.body_json().await?; let cred = obj.value; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -751,7 +756,7 @@ pub async fn account_delete_id_unix_credential(req: tide::Request) -> let attr = "unix_password".to_string(); let filter = filter_all!(f_eq("class", PartialValue::new_class("posixaccount"))); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -806,7 +811,7 @@ pub async fn group_post_id_unix(mut req: tide::Request) -> tide::Resul let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; let obj: GroupUnixExtend = req.body_json().await?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -819,7 +824,7 @@ pub async fn group_get_id_unix_token(req: tide::Request) -> tide::Resu let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -857,7 +862,7 @@ pub async fn recycle_bin_get(req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let attrs = None; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -873,7 +878,7 @@ pub async fn recycle_bin_id_get(req: tide::Request) -> tide::Result { let filter = filter_all!(f_id(id.as_str())); let attrs = None; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -889,7 +894,7 @@ pub async fn recycle_bin_revive_id_post(req: tide::Request) -> tide::R let id = req.get_url_param("id")?; let filter = filter_all!(f_id(id.as_str())); - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -908,7 +913,7 @@ pub async fn auth(mut req: tide::Request) -> tide::Result { // First, deal with some state management. // Do anything here first that's needed like getting the session details // out of the req cookie. - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let maybe_sessionid = req.get_current_auth_session_id(); debug!("🍿 {:?}", maybe_sessionid); @@ -987,7 +992,12 @@ pub async fn auth(mut req: tide::Request) -> tide::Result { // Remove the auth-session-id let msession = req.session_mut(); msession.remove("auth-session-id"); - Ok(ProtoAuthState::Success(token)) + // Create a session cookie? + msession.remove("bearer"); + msession + .insert("bearer", token.clone()) + .map_err(|_| OperationError::InvalidSessionState) + .map(|_| ProtoAuthState::Success(token)) } AuthState::Denied(reason) => { debug!("🧩 -> AuthState::Denied"); @@ -1016,7 +1026,7 @@ pub async fn idm_account_set_password(mut req: tide::Request) -> tide: let uat = req.get_current_uat(); let obj: SingleStringRequest = req.body_json().await?; let cleartext = obj.value; - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let res = req .state() .qe_w_ref @@ -1029,7 +1039,7 @@ pub async fn idm_account_set_password(mut req: tide::Request) -> tide: pub async fn status(req: tide::Request) -> tide::Result { // We ignore the body in this req - let (eventid, hvalue) = new_eventid!(); + let (eventid, hvalue) = req.new_eventid(); let r = req .state() .status_ref @@ -1209,6 +1219,20 @@ pub fn create_https_server( .at("/openid-configuration") .get(get_openid_configuration); + // == oauth endpoints. + + let mut oauth2_process = tserver.at("/oauth2"); + oauth2_process.at("/authorise").get(oauth2_authorise_get); + oauth2_process + .at("/authorise/permit") + .get(oauth2_authorise_permit_get); + oauth2_process.at("/token").post(oauth2_token_post); + /* + oauth2_process + .at("/token/introspect") + .get(oauth2_token_introspect_get); + */ + let mut raw_route = tserver.at("/v1/raw"); raw_route.at("/create").post(create); raw_route.at("/modify").post(modify); @@ -1239,6 +1263,19 @@ pub fn create_https_server( .put(do_nothing) .patch(do_nothing); + let mut oauth2_route = tserver.at("/v1/oauth2"); + oauth2_route.at("/").get(oauth2_get); + + oauth2_route.at("/_basic").post(oauth2_basic_post); + + oauth2_route + .at("/:id") + .get(oauth2_id_get) + // It's not really possible to replace this wholesale. + // .put(oauth2_id_put) + .patch(oauth2_id_patch) + .delete(oauth2_id_delete); + let mut self_route = tserver.at("/v1/self"); self_route.at("/").get(whoami); diff --git a/kanidmd/src/lib/core/https/oauth2.rs b/kanidmd/src/lib/core/https/oauth2.rs new file mode 100644 index 000000000..611fff001 --- /dev/null +++ b/kanidmd/src/lib/core/https/oauth2.rs @@ -0,0 +1,340 @@ +use super::{ + json_rest_event_get, json_rest_event_post, to_tide_response, AppState, RequestExtensions, +}; +use crate::idm::oauth2::{ + AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, ErrorResponse, Oauth2Error, +}; +use crate::prelude::*; +use kanidm_proto::v1::Entry as ProtoEntry; + +// == Oauth2 Configuration Endpoints == + +pub async fn oauth2_get(req: tide::Request) -> tide::Result { + let filter = filter_all!(f_eq( + "class", + PartialValue::new_class("oauth2_resource_server") + )); + json_rest_event_get(req, filter, None).await +} + +pub async fn oauth2_basic_post(req: tide::Request) -> tide::Result { + let classes = vec![ + "oauth2_resource_server".to_string(), + "oauth2_resource_server_basic".to_string(), + "object".to_string(), + ]; + json_rest_event_post(req, classes).await +} + +fn oauth2_id(id: &str) -> Filter { + filter_all!(f_and!([ + f_eq("class", PartialValue::new_class("oauth2_resource_server")), + f_eq("oauth2_rs_name", PartialValue::new_iname(id)) + ])) +} + +pub async fn oauth2_id_get(req: tide::Request) -> tide::Result { + // Get a specific config + let uat = req.get_current_uat(); + let id = req.get_url_param("id")?; + + let filter = oauth2_id(&id); + + let (eventid, hvalue) = req.new_eventid(); + + let res = req + .state() + .qe_r_ref + .handle_internalsearch(uat, filter, None, eventid) + .await + .map(|mut r| r.pop()); + to_tide_response(res, hvalue) +} + +pub async fn oauth2_id_patch(mut req: tide::Request) -> tide::Result { + // Update a value / attrs + let uat = req.get_current_uat(); + let id = req.get_url_param("id")?; + + let obj: ProtoEntry = req.body_json().await?; + + let filter = oauth2_id(&id); + + let (eventid, hvalue) = req.new_eventid(); + + let res = req + .state() + .qe_w_ref + .handle_internalpatch(uat, filter, obj, eventid) + .await; + to_tide_response(res, hvalue) +} + +pub async fn oauth2_id_delete(req: tide::Request) -> tide::Result { + // Delete this + let uat = req.get_current_uat(); + let id = req.get_url_param("id")?; + + let filter = oauth2_id(&id); + + let (eventid, hvalue) = req.new_eventid(); + + let res = req + .state() + .qe_w_ref + .handle_internaldelete(uat, filter, eventid) + .await; + to_tide_response(res, hvalue) +} + +// == OAUTH2 PROTOCOL FLOW HANDLERS == + +// oauth2 (partial) +// https://tools.ietf.org/html/rfc6749 +// oauth2 pkce +// https://tools.ietf.org/html/rfc7636 + +// TODO +// oauth2 token introspection +// https://tools.ietf.org/html/rfc7662 +// oauth2 bearer token +// https://tools.ietf.org/html/rfc6750 + +// From https://datatracker.ietf.org/doc/html/rfc6749#section-4.1 +// +// +----------+ +// | Resource | +// | Owner | +// | | +// +----------+ +// ^ +// | +// (B) +// +----|-----+ Client Identifier +---------------+ +// | -+----(A)-- & Redirection URI ---->| | +// | User- | | Authorization | +// | Agent -+----(B)-- User authenticates --->| Server | +// | | | | +// | -+----(C)-- Authorization Code ---<| | +// +-|----|---+ +---------------+ +// | | ^ v +// (A) (C) | | +// | | | | +// ^ v | | +// +---------+ | | +// | |>---(D)-- Authorization Code ---------' | +// | Client | & Redirection URI | +// | | | +// | |<---(E)----- Access Token -------------------' +// +---------+ (w/ Optional Refresh Token) +// +// Note: The lines illustrating steps (A), (B), and (C) are broken into +// two parts as they pass through the user-agent. +// +// In this diagram, kanidm is the authorisation server. Each step is handled by: +// +// * Client Identifier A) oauth2_authorise_get +// * User authenticates B) normal kanidm auth flow +// * Authorization Code C) oauth2_authorise_permit_get +// * Authorization Code/ +// Access Token D/E) oauth2_token_post +// +// These functions appear stateless, but the state is managed through encrypted +// tokens transmitted in the responses of this flow. This is because in a HA setup +// we can not guarantee that the User-Agent or the Resource Server (client) will +// access the same Kanidm instance, and we can not rely on replication in these +// cases. As a result, we must have our state in localised tokens so that any +// valid Kanidm instance in the topology can handle these request. +// + +pub async fn oauth2_authorise_get(req: tide::Request) -> tide::Result { + // Start the oauth2 authorisation flow to present to the user. + debug!("Request Query - {:?}", req.url().query()); + // Get the authorisation request. + let auth_req: AuthorisationRequest = req.query().map_err(|e| { + error!("{:?}", e); + tide::Error::from_str( + tide::StatusCode::BadRequest, + "Invalid Oauth2 AuthorisationRequest", + ) + })?; + + let uat = req.get_current_uat(); + let (eventid, hvalue) = req.new_eventid(); + + let mut redir_url = auth_req.redirect_uri.clone(); + + let res = req + .state() + .qe_r_ref + .handle_oauth2_authorise(uat, auth_req, eventid) + .await; + + match res { + Ok(consent_req) => { + // Render a redirect to the consent page for the user to interact with + // to authorise this session-id + let mut res = tide::Response::new(200); + // This is json so later we can expand it with better detail. + tide::Body::from_json(&consent_req).map(|b| { + res.set_body(b); + res + }) + } + Err(Oauth2Error::AuthenticationRequired) => { + // This will trigger our ui to auth and retry. + Ok(tide::Response::new(tide::StatusCode::Unauthorized)) + } + Err(e) => { + redir_url + .query_pairs_mut() + .clear() + .append_pair( + "error_description", + &format!("Unable to authorise - Error ID: {}", hvalue), + ) + .append_pair("error", &e.to_string()); + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 + // Return an error, explaining why it was denied. + let mut res = tide::Response::new(302); + res.insert_header("Location", redir_url.as_str()); + Ok(res) + } + } + .map(|mut res| { + res.insert_header("Cache-Control", "no-store"); + res.insert_header("Pragma", "no-cache"); + res.insert_header("X-KANIDM-OPID", hvalue); + res + }) +} + +#[derive(Serialize, Deserialize, Debug)] +struct ConsentRequestData { + token: String, +} + +pub async fn oauth2_authorise_permit_get(req: tide::Request) -> tide::Result { + // When this is called, this indicates consent to proceed from the user. + debug!("Request Query - {:?}", req.url().query()); + + let consent_req: ConsentRequestData = req.query().map_err(|e| { + error!("{:?}", e); + tide::Error::from_str( + tide::StatusCode::BadRequest, + "Invalid Oauth2 Consent Permit", + ) + })?; + + let uat = req.get_current_uat(); + let (eventid, hvalue) = req.new_eventid(); + + let res = req + .state() + .qe_r_ref + .handle_oauth2_authorise_permit(uat, consent_req.token, eventid) + .await; + + let mut res = match res { + Ok(AuthorisePermitSuccess { + mut redirect_uri, + state, + code, + }) => { + let mut res = tide::Response::new(302); + redirect_uri + .query_pairs_mut() + .clear() + .append_pair("state", &state.to_string()) + .append_pair("code", &code); + res.insert_header("Location", redirect_uri.as_str()); + res + } + Err(_e) => { + // If an error happens in our consent flow, I think + // that we should NOT redirect to the calling application + // and we need to handle that locally somehow. + // This needs to be better! + tide::Response::new(500) + } + }; + res.insert_header("Cache-Control", "no-store"); + res.insert_header("Pragma", "no-cache"); + res.insert_header("X-KANIDM-OPID", hvalue); + Ok(res) +} + +pub async fn oauth2_token_post(mut req: tide::Request) -> tide::Result { + // This is called directly by the resource server, where we then issue + // the token to the caller. + let (eventid, hvalue) = req.new_eventid(); + + // Get the authz header (if present). In the future depending on the + // type of exchanges we support, this could become an Option type. + let client_authz = req + .header("authorization") + .and_then(|hv| hv.get(0)) + .and_then(|h| h.as_str().strip_prefix("Basic ")) + .map(str::to_string) + .ok_or_else(|| { + error!("Basic Authentication Not Provided"); + tide::Error::from_str( + tide::StatusCode::Unauthorized, + "Invalid Basic Authorisation", + ) + })?; + + // Get the accessToken Request + let tok_req: AccessTokenRequest = req.body_form().await.map_err(|e| { + error!("atr parse error - {:?}", e); + tide::Error::from_str( + tide::StatusCode::BadRequest, + "Invalid Oauth2 AccessTokenRequest", + ) + })?; + + let res = req + .state() + .qe_r_ref + .handle_oauth2_token_exchange(client_authz, tok_req, eventid) + .await; + + match res { + Ok(atr) => { + let mut res = tide::Response::new(200); + tide::Body::from_json(&atr).map(|b| { + res.set_body(b); + res + }) + } + Err(Oauth2Error::AuthenticationRequired) => { + // This will trigger our ui to auth and retry. + Ok(tide::Response::new(tide::StatusCode::Unauthorized)) + } + Err(e) => { + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + let err = ErrorResponse { + error: e.to_string(), + error_description: None, + error_uri: None, + }; + + let mut res = tide::Response::new(400); + tide::Body::from_json(&err).map(|b| { + res.set_body(b); + res + }) + } + } + .map(|mut res| { + res.insert_header("Cache-Control", "no-store"); + res.insert_header("Pragma", "no-cache"); + res.insert_header("X-KANIDM-OPID", hvalue); + res + }) +} + +/* +pub async fn oauth2_token_introspect_get(req: tide::Request) -> tide::Result { +} +*/ diff --git a/kanidmd/src/lib/core/mod.rs b/kanidmd/src/lib/core/mod.rs index f74a45269..a3bfea413 100644 --- a/kanidmd/src/lib/core/mod.rs +++ b/kanidmd/src/lib/core/mod.rs @@ -91,7 +91,7 @@ fn setup_qs_idms( config: &Configuration, ) -> Result<(QueryServer, IdmServer, IdmServerDelayed), OperationError> { // Create a query_server implementation - let query_server = QueryServer::new(be, schema); + let query_server = QueryServer::new(audit, be, schema); // TODO #62: Should the IDM parts be broken out to the IdmServer? // What's important about this initial setup here is that it also triggers @@ -380,7 +380,7 @@ pub fn verify_server_core(config: &Configuration) { return; } }; - let server = QueryServer::new(be, schema_mem); + let server = QueryServer::new(&mut audit, be, schema_mem); // Run verifications. let r = server.verify(&mut audit); diff --git a/kanidmd/src/lib/entry.rs b/kanidmd/src/lib/entry.rs index da51d3db1..70ea0adce 100644 --- a/kanidmd/src/lib/entry.rs +++ b/kanidmd/src/lib/entry.rs @@ -1694,10 +1694,9 @@ impl Entry { } #[inline(always)] - /// Return a single radius credential, if valid to transform this value. - pub fn get_ava_single_radiuscred(&self, attr: &str) -> Option<&str> { - self.get_ava_single(attr) - .and_then(|a| a.get_radius_secret()) + /// Return a single secret value, if valid to transform this value. + pub fn get_ava_single_secret(&self, attr: &str) -> Option<&str> { + self.get_ava_single(attr).and_then(|a| a.get_secret_str()) } #[inline(always)] @@ -1712,6 +1711,12 @@ impl Entry { self.get_ava_single(attr).and_then(|v| v.to_str()) } + #[inline(always)] + /// Return a single `&Url`, if valid to transform this value. + pub fn get_ava_single_url(&self, attr: &str) -> Option<&Url> { + self.get_ava_single(attr).and_then(|v| v.to_url()) + } + #[inline(always)] /// Return a single protocol filter, if valid to transform this value. pub fn get_ava_single_protofilter(&self, attr: &str) -> Option<&ProtoFilter> { diff --git a/kanidmd/src/lib/event.rs b/kanidmd/src/lib/event.rs index 577387c04..2bd414bde 100644 --- a/kanidmd/src/lib/event.rs +++ b/kanidmd/src/lib/event.rs @@ -558,16 +558,11 @@ impl ModifyEvent { pub fn from_internal_parts( _audit: &mut AuditScope, ident: Identity, - target_uuid: Uuid, ml: &ModifyList, - filter: Filter, + filter: &Filter, qs: &QueryServerWriteTransaction, ) -> Result { - let f_uuid = filter_all!(f_eq("uuid", PartialValue::new_uuid(target_uuid))); - // Add any supplemental conditions we have. - let f = Filter::join_parts_and(f_uuid, filter); - - let filter_orig = f + let filter_orig = filter .validate(qs.get_schema()) .map_err(OperationError::SchemaViolation)?; let filter = filter_orig.clone().into_ignore_hidden(); diff --git a/kanidmd/src/lib/identity.rs b/kanidmd/src/lib/identity.rs index 305644894..530011af3 100644 --- a/kanidmd/src/lib/identity.rs +++ b/kanidmd/src/lib/identity.rs @@ -53,7 +53,7 @@ pub enum IdentType { Internal, } -#[derive(Debug, Clone, PartialEq, Hash, Ord, PartialOrd, Eq)] +#[derive(Debug, Clone, PartialEq, Hash, Ord, PartialOrd, Eq, Serialize, Deserialize)] /// A unique identifier of this Identity, that can be associated to various /// caching components. pub enum IdentityId { diff --git a/kanidmd/src/lib/idm/account.rs b/kanidmd/src/lib/idm/account.rs index 8e7f89430..7e44b8045 100644 --- a/kanidmd/src/lib/idm/account.rs +++ b/kanidmd/src/lib/idm/account.rs @@ -67,8 +67,8 @@ macro_rules! try_from_entry { let expire = $value.get_ava_single_datetime("account_expire"); let radius_secret = $value - .get_ava_single_radiuscred("radius_secret") - .map(|s| s.to_string()); + .get_ava_single_secret("radius_secret") + .map(str::to_string); // Resolved by the caller let groups = $groups; @@ -176,14 +176,14 @@ impl Account { Some(UserAuthToken { session_id, expiry, - name: self.name.clone(), + // name: self.name.clone(), spn: self.spn.clone(), - displayname: self.displayname.clone(), + // displayname: self.displayname.clone(), uuid: self.uuid, // application: None, - groups: self.groups.iter().map(|g| g.to_proto()).collect(), + // groups: self.groups.iter().map(|g| g.to_proto()).collect(), // claims: claims.iter().map(|c| c.to_proto()).collect(), - claims: Vec::new(), + // claims: Vec::new(), auth_type, // What's the best way to get access to these limits with regard to claims/other? lim_uidx: false, @@ -437,7 +437,7 @@ impl Account { &self, cleartext: &str, ) -> Result, OperationError> { - let vcred = Value::new_radius_str(cleartext); + let vcred = Value::new_secret_str(cleartext); Ok(ModifyList::new_purge_and_set("radius_secret", vcred)) } diff --git a/kanidmd/src/lib/idm/mod.rs b/kanidmd/src/lib/idm/mod.rs index dd76f7ad9..876ba2d10 100644 --- a/kanidmd/src/lib/idm/mod.rs +++ b/kanidmd/src/lib/idm/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod delayed; pub(crate) mod event; pub(crate) mod group; pub(crate) mod mfareg; +pub(crate) mod oauth2; pub(crate) mod radius; pub mod server; pub(crate) mod unix; diff --git a/kanidmd/src/lib/idm/oauth2.rs b/kanidmd/src/lib/idm/oauth2.rs new file mode 100644 index 000000000..94d5282f1 --- /dev/null +++ b/kanidmd/src/lib/idm/oauth2.rs @@ -0,0 +1,1021 @@ +//! Oauth2 resource server configurations +//! +//! This contains the in memory and loaded set of active oauth2 resource server +//! integrations, which are then able to be used an accessed from the IDM layer +//! for operations involving oauth2 authentication processing. +//! + +use crate::identity::IdentityId; +use crate::prelude::*; +use concread::cowcell::*; +use fernet::Fernet; +use hashbrown::HashMap; +use kanidm_proto::v1::UserAuthToken; +use openssl::sha; +use time::OffsetDateTime; +use url::{Origin, Url}; +use webauthn_rs::base64_data::Base64UrlSafeData; + +pub use kanidm_proto::oauth2::{ + AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, + ConsentRequest, ErrorResponse, +}; + +use std::convert::TryFrom; +use std::time::Duration; + +lazy_static! { + static ref CLASS_OAUTH2: PartialValue = PartialValue::new_class("oauth2_resource_server"); + static ref CLASS_OAUTH2_BASIC: PartialValue = + PartialValue::new_class("oauth2_resource_server_basic"); +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Oauth2Error { + // Non-standard + AuthenticationRequired, + // Standard + InvalidRequest, + UnauthorizedClient, + AccessDenied, + UnsupportedResponseType, + InvalidScope, + ServerError(OperationError), + TemporarilyUnavailable, +} + +impl std::fmt::Display for Oauth2Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Oauth2Error::AuthenticationRequired => "authentication_required", + Oauth2Error::InvalidRequest => "invalid_request", + Oauth2Error::UnauthorizedClient => "unauthorized_client", + Oauth2Error::AccessDenied => "access_denied", + Oauth2Error::UnsupportedResponseType => "unsupported_response_type", + Oauth2Error::InvalidScope => "invalid_scope", + Oauth2Error::ServerError(_) => "server_error", + Oauth2Error::TemporarilyUnavailable => "temporarily_unavailable", + }) + } +} + +// == internal state formats that we encrypt and send. + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +struct ConsentToken { + pub client_id: String, + // Must match the session id of the Uat, + pub session_id: Uuid, + // So we can ensure that we really match the same uat to prevent confusions. + pub ident_id: IdentityId, + // CSRF + pub state: Base64UrlSafeData, + // The S256 code challenge. + pub code_challenge: Base64UrlSafeData, + // Where the RS wants us to go back to. + pub redirect_uri: Url, +} + +// consent token? + +#[derive(Serialize, Deserialize, Debug)] +struct TokenExchangeCode { + // We don't need the client_id here, because it's signed with an RS specific + // key which gives us the assurance that it's the correct combination. + pub uat: UserAuthToken, + // The S256 code challenge. + pub code_challenge: Base64UrlSafeData, + // The original redirect uri + pub redirect_uri: Url, +} + +// consentPermitResponse + +#[derive(Debug)] +pub struct AuthorisePermitSuccess { + // Where the RS wants us to go back to. + pub redirect_uri: Url, + // The CSRF as a string + pub state: Base64UrlSafeData, + // The exchange code as a String + pub code: String, +} + +// The cache structure + +#[derive(Clone)] +pub struct Oauth2RSBasic { + name: String, + uuid: Uuid, + origin: Origin, + authz_secret: String, + token_fernet: Fernet, +} + +impl std::fmt::Debug for Oauth2RSBasic { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Oauth2RSBasic") + .field("name", &self.name) + .field("uuid", &self.uuid) + .field("origin", &self.origin) + .finish() + } +} + +#[derive(Debug, Clone)] +pub enum Oauth2RS { + Basic(Oauth2RSBasic), +} + +#[derive(Clone)] +struct Oauth2RSInner { + fernet: Fernet, + rs_set: HashMap, +} + +pub struct Oauth2ResourceServers { + inner: CowCell, +} + +pub struct Oauth2ResourceServersReadTransaction { + inner: CowCellReadTxn, +} + +pub struct Oauth2ResourceServersWriteTransaction<'a> { + inner: CowCellWriteTxn<'a, Oauth2RSInner>, +} + +impl TryFrom> for Oauth2ResourceServers { + type Error = OperationError; + + fn try_from(value: Vec) -> Result { + let fernet = + Fernet::new(&Fernet::generate_key()).ok_or(OperationError::CryptographyError)?; + let oauth2rs = Oauth2ResourceServers { + inner: CowCell::new(Oauth2RSInner { + fernet, + rs_set: HashMap::new(), + }), + }; + + let mut oauth2rs_wr = oauth2rs.write(); + oauth2rs_wr.reload(value)?; + oauth2rs_wr.commit(); + Ok(oauth2rs) + } +} + +impl Oauth2ResourceServers { + pub fn read(&self) -> Oauth2ResourceServersReadTransaction { + Oauth2ResourceServersReadTransaction { + inner: self.inner.read(), + } + } + + pub fn write(&self) -> Oauth2ResourceServersWriteTransaction { + Oauth2ResourceServersWriteTransaction { + inner: self.inner.write(), + } + } +} + +impl<'a> Oauth2ResourceServersWriteTransaction<'a> { + pub fn reload(&mut self, value: Vec) -> Result<(), OperationError> { + let rs_set: Result, _> = value + .into_iter() + .map(|ent| { + // From each entry, attempt to make an oauth2 configuration. + if !ent.attribute_equality("class", &CLASS_OAUTH2) { + // Check we have oauth2_resource_server class + Err(OperationError::InvalidEntryState) + } else if ent.attribute_equality("class", &CLASS_OAUTH2_BASIC) { + // If we have oauth2_resource_server_basic + // Now we know we can load the attrs. + let uuid = *ent.get_uuid(); + let name = ent + .get_ava_single_str("oauth2_rs_name") + .map(str::to_string) + .ok_or(OperationError::InvalidValueState)?; + let origin = ent + .get_ava_single_url("oauth2_rs_origin") + .map(|url| url.origin()) + .ok_or(OperationError::InvalidValueState)?; + let authz_secret = ent + .get_ava_single_str("oauth2_rs_basic_secret") + .map(str::to_string) + .ok_or(OperationError::InvalidValueState)?; + let token_fernet = ent + .get_ava_single_secret("oauth2_rs_basic_token_key") + .ok_or(OperationError::InvalidValueState) + .and_then(|key| { + Fernet::new(&key).ok_or(OperationError::CryptographyError) + })?; + + // Currently unsure if this is how I want to handle this. + // let oauth2_rs_account_filter = ent.get_ava_single_protofilter("oauth2_rs_account_filter") + + let client_id = name.clone(); + let rscfg = Oauth2RS::Basic(Oauth2RSBasic { + name, + uuid, + origin, + authz_secret, + token_fernet, + }); + + Ok((client_id, rscfg)) + } else { + Err(OperationError::InvalidEntryState) + } + }) + .collect(); + + rs_set.map(|mut rs_set| { + // Delay getting the inner mut (which may clone) until we know we are ok. + let inner_ref = self.inner.get_mut(); + // Swap them if we are ok + std::mem::swap(&mut inner_ref.rs_set, &mut rs_set); + }) + } + + pub fn commit(self) { + self.inner.commit(); + } +} + +impl Oauth2ResourceServersReadTransaction { + pub fn check_oauth2_authorisation( + &self, + audit: &mut AuditScope, + ident: &Identity, + uat: &UserAuthToken, + auth_req: &AuthorisationRequest, + ct: Duration, + ) -> Result { + // due to identity processing we already know that: + // * the session must be authenticated, and valid + // * is within it's valid time window. + + if auth_req.response_type != "code" { + ladmin_warning!(audit, "Invalid oauth2 response_type (should be 'code')"); + return Err(Oauth2Error::UnsupportedResponseType); + } + + // CodeChallengeMethod must be S256 + if auth_req.code_challenge_method != CodeChallengeMethod::S256 { + ladmin_warning!( + audit, + "Invalid oauth2 code_challenge_method (must be 'S256')" + ); + return Err(Oauth2Error::InvalidRequest); + } + + let o2rs = self.inner.rs_set.get(&auth_req.client_id).ok_or_else(|| { + ladmin_warning!( + audit, + "Invalid oauth2 client_id (have you configured the oauth2 resource server?)" + ); + Oauth2Error::InvalidRequest + })?; + + // scopes + + // user authorisation filter + + // Subseqent we then return an encrypted session handle which allows + // the user to indicate their consent to this authorisation. + // + // This session handle is what we use in "permit" to generate the redirect. + + match o2rs { + Oauth2RS::Basic(rsbasic) => { + // redirect_uri must be part of the client_id origin. + if auth_req.redirect_uri.origin() != rsbasic.origin { + ladmin_warning!( + audit, + "Invalid oauth2 redirect_uri (must be related to origin of {:?})", + rsbasic.origin + ); + return Err(Oauth2Error::InvalidRequest); + } + } + }; + + let consent_req = ConsentToken { + client_id: auth_req.client_id.clone(), + ident_id: ident.get_event_origin_id(), + session_id: uat.session_id, + state: auth_req.state.clone(), + code_challenge: auth_req.code_challenge.clone(), + redirect_uri: auth_req.redirect_uri.clone(), + }; + + let consent_data = serde_json::to_vec(&consent_req).map_err(|e| { + ladmin_error!(audit, "Unable to encode consent data {:?}", e); + Oauth2Error::ServerError(OperationError::SerdeJsonError) + })?; + + let consent_token = self + .inner + .fernet + .encrypt_at_time(&consent_data, ct.as_secs()); + + Ok(ConsentRequest { + client_name: auth_req.client_id.clone(), + scopes: Vec::new(), + consent_token, + }) + } + + pub fn check_oauth2_authorise_permit( + &self, + audit: &mut AuditScope, + ident: &Identity, + uat: &UserAuthToken, + consent_token: &str, + ct: Duration, + ) -> Result { + // Decode the consent req with our system fernet key. Use a ttl of 5 minutes. + let consent_req: ConsentToken = self + .inner + .fernet + .decrypt_at_time(consent_token, Some(300), ct.as_secs()) + .map_err(|_| { + ladmin_error!(audit, "Failed to decrypt consent request"); + OperationError::CryptographyError + }) + .and_then(|data| { + serde_json::from_slice(&data).map_err(|e| { + ladmin_error!(audit, "Failed to deserialise consent request - {:?}", e); + OperationError::SerdeJsonError + }) + })?; + + // Validate that the ident_id matches our current ident. + if consent_req.ident_id != ident.get_event_origin_id() { + lsecurity!( + audit, + "consent request ident id does not match the identity of our UAT." + ); + return Err(OperationError::InvalidSessionState); + } + + // Validate that the session id matches our uat. + if consent_req.session_id != uat.session_id { + lsecurity!( + audit, + "consent request sessien id does not match the session id of our UAT." + ); + return Err(OperationError::InvalidSessionState); + } + + // Get the resource server config based on this client_id. + let o2rs_fernet = match self.inner.rs_set.get(&consent_req.client_id) { + Some(Oauth2RS::Basic(rsbasic)) => &rsbasic.token_fernet, + None => { + ladmin_error!(audit, "Invalid consent request oauth2 client_id"); + return Err(OperationError::InvalidRequestState); + } + }; + + // Extract the state, code challenge, redirect_uri + + let xchg_code = TokenExchangeCode { + uat: uat.clone(), + code_challenge: consent_req.code_challenge, + redirect_uri: consent_req.redirect_uri.clone(), + }; + + // Encrypt the exchange token with the fernet key of the client resource server + let code_data = serde_json::to_vec(&xchg_code).map_err(|e| { + ladmin_error!(audit, "Unable to encode xchg_code data {:?}", e); + OperationError::SerdeJsonError + })?; + + let code = o2rs_fernet.encrypt_at_time(&code_data, ct.as_secs()); + + Ok(AuthorisePermitSuccess { + redirect_uri: consent_req.redirect_uri, + state: consent_req.state, + code, + }) + } + + pub fn check_oauth2_token_exchange( + &self, + audit: &mut AuditScope, + client_authz: &str, + token_req: &AccessTokenRequest, + ct: Duration, + ) -> Result { + if token_req.grant_type != "authorization_code" { + ladmin_warning!( + audit, + "Invalid oauth2 grant_type (should be 'authorization_code')" + ); + return Err(Oauth2Error::InvalidRequest); + } + + // Check the client_authz + let authz = base64::decode(&client_authz) + .map_err(|_| { + ladmin_error!(audit, "Basic authz invalid base64"); + Oauth2Error::AuthenticationRequired + }) + .and_then(|data| { + String::from_utf8(data).map_err(|_| { + ladmin_error!(audit, "Basic authz invalid utf8"); + Oauth2Error::AuthenticationRequired + }) + })?; + + // Get the first :, it should be our delim. + // + let mut split_iter = authz.split(':'); + + let client_id = split_iter.next().ok_or_else(|| { + ladmin_error!(audit, "Basic authz invalid format (corrupt input?)"); + Oauth2Error::AuthenticationRequired + })?; + let secret = split_iter.next().ok_or_else(|| { + ladmin_error!(audit, "Basic authz invalid format (missing ':' seperator?)"); + Oauth2Error::AuthenticationRequired + })?; + + // Get the o2rs for the handle. + let o2rs = self.inner.rs_set.get(client_id).ok_or_else(|| { + ladmin_warning!(audit, "Invalid oauth2 client_id"); + Oauth2Error::AuthenticationRequired + })?; + + // check the secret. + let o2rs_fernet = match o2rs { + Oauth2RS::Basic(rsbasic) => { + if rsbasic.authz_secret != secret { + lsecurity!(audit, "Invalid oauth2 client_id secret"); + return Err(Oauth2Error::AuthenticationRequired); + } + // We are authenticated! Yay! Now we can actually check things ... + &rsbasic.token_fernet + } + }; + + // Check the token_req is within the valid time, and correctly signed for + // this client. + + let code_xchg: TokenExchangeCode = o2rs_fernet + .decrypt_at_time(&token_req.code, Some(60), ct.as_secs()) + .map_err(|_| { + ladmin_error!(audit, "Failed to decrypt token exchange request"); + Oauth2Error::InvalidRequest + }) + .and_then(|data| { + serde_json::from_slice(&data).map_err(|e| { + ladmin_error!(audit, "Failed to deserialise token exchange code - {:?}", e); + Oauth2Error::InvalidRequest + }) + })?; + + // Validate the code_verifier + let mut hasher = sha::Sha256::new(); + hasher.update(token_req.code_verifier.as_bytes()); + let code_verifier_hash: Vec = hasher.finish().iter().copied().collect(); + + if code_xchg.code_challenge.0 != code_verifier_hash { + lsecurity!( + audit, + "PKCE code verification failed - this may indicate malicious activity" + ); + return Err(Oauth2Error::InvalidRequest); + } + + // Validate the redirect_uri is the same as the original. + if token_req.redirect_uri != code_xchg.redirect_uri { + ladmin_warning!( + audit, + "Invalid oauth2 redirect_uri (differs from original request uri)" + ); + return Err(Oauth2Error::InvalidRequest); + } + + // We are now GOOD TO GO! + // Use this to grant the access token response. + let odt_ct = OffsetDateTime::unix_epoch() + ct; + + let expires_in = if code_xchg.uat.expiry > odt_ct { + // Becomes a duration. + (code_xchg.uat.expiry - odt_ct).whole_seconds() as u32 + } else { + lsecurity!( + audit, + "User Auth Token has expired before we could publish the oauth2 response" + ); + return Err(Oauth2Error::AccessDenied); + }; + + let access_token = serde_json::to_vec(&code_xchg.uat) + .map_err(|e| { + ladmin_error!(audit, "Unable to encode uat data {:?}", e); + Oauth2Error::ServerError(OperationError::SerdeJsonError) + }) + .map(|data| o2rs_fernet.encrypt_at_time(&data, ct.as_secs()))?; + + Ok(AccessTokenResponse { + access_token, + token_type: "bearer".to_string(), + expires_in, + refresh_token: None, + scope: None, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::event::CreateEvent; + use crate::idm::oauth2::Oauth2Error; + use crate::idm::server::{IdmServer, IdmServerTransaction}; + use crate::prelude::*; + + use kanidm_proto::oauth2::*; + use kanidm_proto::v1::{AuthType, UserAuthToken}; + use webauthn_rs::base64_data::Base64UrlSafeData; + + use openssl::sha; + + use std::time::Duration; + + const TEST_CURRENT_TIME: u64 = 6000; + const UAT_EXPIRE: u64 = 5; + const TOKEN_EXPIRE: u64 = 900; + + macro_rules! create_code_verifier { + ($key:expr) => {{ + let code_verifier = $key.to_string(); + let mut hasher = sha::Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge: Vec = hasher.finish().iter().copied().collect(); + (code_verifier, code_challenge) + }}; + } + + macro_rules! good_authorisation_request { + ( + $audit:expr, + $idms_prox_read:expr, + $ident:expr, + $uat:expr, + $ct:expr, + $code_challenge:expr + ) => {{ + let auth_req = AuthorisationRequest { + response_type: "code".to_string(), + client_id: "test_resource_server".to_string(), + state: Base64UrlSafeData(vec![1, 2, 3]), + code_challenge: Base64UrlSafeData($code_challenge), + code_challenge_method: CodeChallengeMethod::S256, + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + scope: "".to_string(), + }; + + $idms_prox_read + .check_oauth2_authorisation($audit, $ident, $uat, &auth_req, $ct) + .expect("Oauth2 authorisation failed") + }}; + } + + // setup an oauth2 instance. + fn setup_oauth2_resource_server( + audit: &mut AuditScope, + idms: &IdmServer, + ct: Duration, + ) -> (String, UserAuthToken, Identity) { + let mut idms_prox_write = idms.proxy_write(ct); + + let uuid = Uuid::new_v4(); + + let e: Entry = entry_init!( + ("class", Value::new_class("object")), + ("class", Value::new_class("oauth2_resource_server")), + ("class", Value::new_class("oauth2_resource_server_basic")), + ("uuid", Value::new_uuid(uuid)), + ("oauth2_rs_name", Value::new_iname("test_resource_server")), + ( + "oauth2_rs_origin", + Value::new_url_s("https://demo.example.com").unwrap() + ) + ); + let ce = CreateEvent::new_internal(vec![e]); + assert!(idms_prox_write.qs_write.create(audit, &ce).is_ok()); + + let entry = idms_prox_write + .qs_write + .internal_search_uuid(audit, &uuid) + .expect("Failed to retrieve oauth2 resource entry "); + let secret = entry + .get_ava_single_str("oauth2_rs_basic_secret") + .map(str::to_string) + .expect("No oauth2_rs_basic_secret found"); + + // Setup the uat we'll be using. + let account = idms_prox_write + .target_to_account(audit, &UUID_ADMIN) + .expect("account must exist"); + let session_id = uuid::Uuid::new_v4(); + let uat = account + .to_userauthtoken(session_id, ct, AuthType::PasswordMfa) + .expect("Unable to create uat"); + let ident = idms_prox_write + .process_uat_to_identity(audit, &uat, ct) + .expect("Unable to process uat"); + + idms_prox_write.commit(audit).expect("failed to commit"); + + (secret, uat, ident) + } + + #[test] + fn test_idm_oauth2_basic_function() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + audit: &mut AuditScope| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, uat, ident) = setup_oauth2_resource_server(audit, idms, ct); + + let idms_prox_read = idms.proxy_read(); + + // Get an ident/uat for now. + + // == Setup the authorisation request + let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + + let consent_request = good_authorisation_request!( + audit, + idms_prox_read, + &ident, + &uat, + ct, + code_challenge + ); + + // == Manually submit the consent token to the permit for the permit_success + let permit_success = idms_prox_read + .check_oauth2_authorise_permit( + audit, + &ident, + &uat, + &consent_request.consent_token, + ct, + ) + .expect("Failed to perform oauth2 permit"); + + // Check we are reflecting the CSRF properly. + assert!(permit_success.state.0 == vec![1, 2, 3]); + + // == Submit the token exchange code. + + let client_authz = base64::encode(format!("test_resource_server:{}", secret)); + let token_req = AccessTokenRequest { + grant_type: "authorization_code".to_string(), + code: permit_success.code, + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + client_id: None, + // From the first step. + code_verifier, + }; + + let token_response = idms_prox_read + .check_oauth2_token_exchange(audit, &client_authz, &token_req, ct) + .expect("Failed to perform oauth2 token exchange"); + + // 🎉 We got a token! In the future we can then check introspection from this point. + assert!(token_response.token_type == "bearer"); + }) + } + + #[test] + fn test_idm_oauth2_invalid_authorisation_requests() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + audit: &mut AuditScope| { + // Test invalid oauth2 authorisation states/requests. + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (_secret, uat, ident) = setup_oauth2_resource_server(audit, idms, ct); + + let idms_prox_read = idms.proxy_read(); + + let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + + // * response type != code. + let auth_req = AuthorisationRequest { + response_type: "NOTCODE".to_string(), + client_id: "test_resource_server".to_string(), + state: Base64UrlSafeData(vec![1, 2, 3]), + code_challenge: Base64UrlSafeData(code_challenge.clone()), + code_challenge_method: CodeChallengeMethod::S256, + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + scope: "".to_string(), + }; + + assert!( + idms_prox_read + .check_oauth2_authorisation(audit, &ident, &uat, &auth_req, ct) + .unwrap_err() + == Oauth2Error::UnsupportedResponseType + ); + + // * invalid rs name + let auth_req = AuthorisationRequest { + response_type: "code".to_string(), + client_id: "NOT A REAL RESOURCE SERVER".to_string(), + state: Base64UrlSafeData(vec![1, 2, 3]), + code_challenge: Base64UrlSafeData(code_challenge.clone()), + code_challenge_method: CodeChallengeMethod::S256, + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + scope: "".to_string(), + }; + + assert!( + idms_prox_read + .check_oauth2_authorisation(audit, &ident, &uat, &auth_req, ct) + .unwrap_err() + == Oauth2Error::InvalidRequest + ); + + // * mis match origin in the redirect. + let auth_req = AuthorisationRequest { + response_type: "code".to_string(), + client_id: "test_resource_server".to_string(), + state: Base64UrlSafeData(vec![1, 2, 3]), + code_challenge: Base64UrlSafeData(code_challenge), + code_challenge_method: CodeChallengeMethod::S256, + redirect_uri: Url::parse("https://totes.not.sus.org/oauth2/result").unwrap(), + scope: "".to_string(), + }; + + assert!( + idms_prox_read + .check_oauth2_authorisation(audit, &ident, &uat, &auth_req, ct) + .unwrap_err() + == Oauth2Error::InvalidRequest + ); + }) + } + + #[test] + fn test_idm_oauth2_invalid_authorisation_permit_requests() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + audit: &mut AuditScope| { + // Test invalid oauth2 authorisation states/requests. + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (_secret, uat, ident) = setup_oauth2_resource_server(audit, idms, ct); + + let (uat2, ident2) = { + let mut idms_prox_write = idms.proxy_write(ct); + let account = idms_prox_write + .target_to_account(audit, &UUID_IDM_ADMIN) + .expect("account must exist"); + let session_id = uuid::Uuid::new_v4(); + let uat2 = account + .to_userauthtoken(session_id, ct, AuthType::PasswordMfa) + .expect("Unable to create uat"); + let ident2 = idms_prox_write + .process_uat_to_identity(audit, &uat2, ct) + .expect("Unable to process uat"); + (uat2, ident2) + }; + + let idms_prox_read = idms.proxy_read(); + + let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + + let consent_request = good_authorisation_request!( + audit, + idms_prox_read, + &ident, + &uat, + ct, + code_challenge + ); + + // Invalid permits + // * expired token, aka past ttl. + assert!( + idms_prox_read + .check_oauth2_authorise_permit( + audit, + &ident, + &uat, + &consent_request.consent_token, + ct + Duration::from_secs(TOKEN_EXPIRE), + ) + .unwrap_err() + == OperationError::CryptographyError + ); + + // * incorrect ident + // We get another uat, but for a different user, and we'll introduce these + // inconsistently to cause confusion. + + assert!( + idms_prox_read + .check_oauth2_authorise_permit( + audit, + &ident2, + &uat, + &consent_request.consent_token, + ct, + ) + .unwrap_err() + == OperationError::InvalidSessionState + ); + + // * incorrect session id + assert!( + idms_prox_read + .check_oauth2_authorise_permit( + audit, + &ident, + &uat2, + &consent_request.consent_token, + ct, + ) + .unwrap_err() + == OperationError::InvalidSessionState + ); + }) + } + + #[test] + fn test_idm_oauth2_invalid_token_exchange_requests() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + audit: &mut AuditScope| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, mut uat, ident) = setup_oauth2_resource_server(audit, idms, ct); + + // ⚠️ We set the uat expiry time to 5 seconds from TEST_CURRENT_TIME. This + // allows all our other tests to pass, but it means when we specifically put the + // clock forward a fraction, the fernet tokens are still valid, but the uat + // is not. + // IE + // |---------------------|------------------| + // TEST_CURRENT_TIME UAT_EXPIRE TOKEN_EXPIRE + // + // This lets us check a variety of time based cases. + uat.expiry = time::OffsetDateTime::unix_epoch() + + Duration::from_secs(TEST_CURRENT_TIME + UAT_EXPIRE - 1); + + let idms_prox_read = idms.proxy_read(); + + // == Setup the authorisation request + let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + let consent_request = good_authorisation_request!( + audit, + idms_prox_read, + &ident, + &uat, + ct, + code_challenge + ); + + // == Manually submit the consent token to the permit for the permit_success + let permit_success = idms_prox_read + .check_oauth2_authorise_permit( + audit, + &ident, + &uat, + &consent_request.consent_token, + ct, + ) + .expect("Failed to perform oauth2 permit"); + + // == Submit the token exchange code. + + // Invalid token exchange + // * invalid client_authz (not base64) + let token_req = AccessTokenRequest { + grant_type: "authorization_code".to_string(), + code: permit_success.code.clone(), + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + client_id: None, + // From the first step. + code_verifier: code_verifier.clone(), + }; + + assert!( + idms_prox_read + .check_oauth2_token_exchange(audit, "not base64", &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); + + // * doesn't have : + let client_authz = base64::encode(format!("test_resource_server {}", secret)); + assert!( + idms_prox_read + .check_oauth2_token_exchange(audit, &client_authz, &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); + + // * invalid client_id + let client_authz = base64::encode(format!("NOT A REAL SERVER:{}", secret)); + assert!( + idms_prox_read + .check_oauth2_token_exchange(audit, &client_authz, &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); + + // * valid client_id, but invalid secret + let client_authz = base64::encode("test_resource_server:12345"); + assert!( + idms_prox_read + .check_oauth2_token_exchange(audit, &client_authz, &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); + + // ✅ Now the valid client_authz is in place. + let client_authz = base64::encode(format!("test_resource_server:{}", secret)); + // * expired exchange code (took too long) + assert!( + idms_prox_read + .check_oauth2_token_exchange( + audit, + &client_authz, + &token_req, + ct + Duration::from_secs(TOKEN_EXPIRE) + ) + .unwrap_err() + == Oauth2Error::InvalidRequest + ); + + // * Uat has expired! + // NOTE: This is setup EARLY in the test, by manipulation of the UAT expiry. + assert!( + idms_prox_read + .check_oauth2_token_exchange( + audit, + &client_authz, + &token_req, + ct + Duration::from_secs(UAT_EXPIRE) + ) + .unwrap_err() + == Oauth2Error::AccessDenied + ); + + // * incorrect grant_type + let token_req = AccessTokenRequest { + grant_type: "INCORRECT GRANT TYPE".to_string(), + code: permit_success.code.clone(), + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + client_id: None, + code_verifier: code_verifier.clone(), + }; + assert!( + idms_prox_read + .check_oauth2_token_exchange(audit, &client_authz, &token_req, ct) + .unwrap_err() + == Oauth2Error::InvalidRequest + ); + + // * Incorrect redirect uri + let token_req = AccessTokenRequest { + grant_type: "authorization_code".to_string(), + code: permit_success.code.clone(), + redirect_uri: Url::parse("https://totes.not.sus.org/oauth2/result").unwrap(), + client_id: None, + code_verifier, + }; + assert!( + idms_prox_read + .check_oauth2_token_exchange(audit, &client_authz, &token_req, ct) + .unwrap_err() + == Oauth2Error::InvalidRequest + ); + + // * code verifier incorrect + let token_req = AccessTokenRequest { + grant_type: "authorization_code".to_string(), + code: permit_success.code, + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + client_id: None, + code_verifier: "12345".to_string(), + }; + assert!( + idms_prox_read + .check_oauth2_token_exchange(audit, &client_authz, &token_req, ct) + .unwrap_err() + == Oauth2Error::InvalidRequest + ); + }) + } +} diff --git a/kanidmd/src/lib/idm/radius.rs b/kanidmd/src/lib/idm/radius.rs index df818c739..a5af0c92c 100644 --- a/kanidmd/src/lib/idm/radius.rs +++ b/kanidmd/src/lib/idm/radius.rs @@ -38,7 +38,7 @@ impl RadiusAccount { } let radius_secret = value - .get_ava_single_radiuscred("radius_secret") + .get_ava_single_secret("radius_secret") .ok_or_else(|| { OperationError::InvalidAccountState("Missing attribute: radius_secret".to_string()) })? diff --git a/kanidmd/src/lib/idm/server.rs b/kanidmd/src/lib/idm/server.rs index 7533ad22c..1a3bb8b46 100644 --- a/kanidmd/src/lib/idm/server.rs +++ b/kanidmd/src/lib/idm/server.rs @@ -13,6 +13,11 @@ use crate::idm::event::{ UnixUserTokenEvent, VerifyTotpEvent, WebauthnDoRegisterEvent, WebauthnInitRegisterEvent, }; use crate::idm::mfareg::{MfaRegCred, MfaRegNext, MfaRegSession}; +use crate::idm::oauth2::{ + AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, + ConsentRequest, Oauth2Error, Oauth2ResourceServers, Oauth2ResourceServersReadTransaction, + Oauth2ResourceServersWriteTransaction, +}; use crate::idm::radius::RadiusAccount; use crate::idm::unix::{UnixGroup, UnixUserAccount}; use crate::idm::AuthState; @@ -56,6 +61,7 @@ use concread::{ hashmap::HashMap, }; use rand::prelude::*; +use std::convert::TryFrom; use std::{sync::Arc, time::Duration}; use url::Url; @@ -85,6 +91,7 @@ pub struct IdmServer { // Our webauthn verifier/config webauthn: Webauthn, pw_badlist_cache: Arc>>, + oauth2rs: Arc, uat_bundy_hmac: Arc>, } @@ -112,6 +119,7 @@ pub struct IdmServerProxyReadTransaction<'a> { // and other structured content. pub qs_read: QueryServerReadTransaction<'a>, uat_bundy_hmac: CowCellReadTxn, + oauth2rs: Oauth2ResourceServersReadTransaction, } pub struct IdmServerProxyWriteTransaction<'a> { @@ -125,6 +133,7 @@ pub struct IdmServerProxyWriteTransaction<'a> { webauthn: &'a Webauthn, pw_badlist_cache: CowCellWriteTxn<'a, HashSet>, uat_bundy_hmac: CowCellWriteTxn<'a, HS512>, + oauth2rs: Oauth2ResourceServersWriteTransaction<'a>, } pub struct IdmServerDelayed { @@ -150,22 +159,16 @@ impl IdmServer { let (async_tx, async_rx) = unbounded(); // Get the domain name, as the relying party id. - let (rp_id, pw_badlist_set) = { + let (rp_id, pw_badlist_set, oauth2rs_set) = { let qs_read = task::block_on(qs.read_async()); ( qs_read.get_domain_name(au)?, qs_read.get_password_badlist(au)?, + // Add a read/reload of all oauth2 configurations. + qs_read.get_oauth2rs_set(au)?, ) }; - let bundy_handle = HS512::generate_key() - .and_then(|bundy_key| HS512::from_str(&bundy_key)) - .map_err(|e| { - ladmin_error!(au, "Failed to generate uat_bundy_hmac - {:?}", e); - OperationError::InvalidState - })?; - let uat_bundy_hmac = Arc::new(CowCell::new(bundy_handle)); - // Check that it gels with our origin. Url::parse(origin.as_str()) .map_err(|_e| { @@ -195,6 +198,17 @@ impl IdmServer { rp_id, }); + // Setup our auth token signing key. + let bundy_handle = HS512::generate_key() + .and_then(|bundy_key| HS512::from_str(&bundy_key)) + .map_err(|e| { + ladmin_error!(au, "Failed to generate uat_bundy_hmac - {:?}", e); + OperationError::InvalidState + })?; + let uat_bundy_hmac = Arc::new(CowCell::new(bundy_handle)); + + let oauth2rs = Oauth2ResourceServers::try_from(oauth2rs_set)?; + Ok(( IdmServer { session_ticket: Semaphore::new(1), @@ -208,6 +222,7 @@ impl IdmServer { webauthn, pw_badlist_cache: Arc::new(CowCell::new(pw_badlist_set)), uat_bundy_hmac, + oauth2rs: Arc::new(oauth2rs), }, IdmServerDelayed { async_rx }, )) @@ -251,6 +266,7 @@ impl IdmServer { IdmServerProxyReadTransaction { qs_read: self.qs.read_async().await, uat_bundy_hmac: self.uat_bundy_hmac.read(), + oauth2rs: self.oauth2rs.read(), } } @@ -273,6 +289,7 @@ impl IdmServer { webauthn: &self.webauthn, pw_badlist_cache: self.pw_badlist_cache.write(), uat_bundy_hmac: self.uat_bundy_hmac.write(), + oauth2rs: self.oauth2rs.write(), } } @@ -1024,6 +1041,41 @@ impl<'a> IdmServerProxyReadTransaction<'a> { account.to_backupcodesview() } + + pub fn check_oauth2_authorisation( + &self, + audit: &mut AuditScope, + ident: &Identity, + uat: &UserAuthToken, + auth_req: &AuthorisationRequest, + ct: Duration, + ) -> Result { + self.oauth2rs + .check_oauth2_authorisation(audit, ident, uat, auth_req, ct) + } + + pub fn check_oauth2_authorise_permit( + &self, + audit: &mut AuditScope, + ident: &Identity, + uat: &UserAuthToken, + consent_req: &str, + ct: Duration, + ) -> Result { + self.oauth2rs + .check_oauth2_authorise_permit(audit, ident, uat, consent_req, ct) + } + + pub fn check_oauth2_token_exchange( + &self, + audit: &mut AuditScope, + client_authz: &str, + token_req: &AccessTokenRequest, + ct: Duration, + ) -> Result { + self.oauth2rs + .check_oauth2_token_exchange(audit, client_authz, token_req, ct) + } } impl<'a> IdmServerTransaction<'a> for IdmServerProxyWriteTransaction<'a> { @@ -1102,7 +1154,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } } - fn target_to_account( + pub(crate) fn target_to_account( &mut self, au: &mut AuditScope, target: &Uuid, @@ -1859,8 +1911,15 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { .contains(&UUID_SYSTEM_CONFIG) { self.reload_password_badlist(au)?; - self.pw_badlist_cache.commit(); }; + if self.qs_write.get_changed_ouath2() { + self.qs_write + .get_oauth2rs_set(au) + .and_then(|oauth2rs_set| self.oauth2rs.reload(oauth2rs_set))?; + } + // Commit everything. + self.oauth2rs.commit(); + self.pw_badlist_cache.commit(); self.mfareg_sessions.commit(); self.qs_write.commit(au) } diff --git a/kanidmd/src/lib/idm/unix.rs b/kanidmd/src/lib/idm/unix.rs index 3afa72906..2c88fdb54 100644 --- a/kanidmd/src/lib/idm/unix.rs +++ b/kanidmd/src/lib/idm/unix.rs @@ -94,8 +94,8 @@ macro_rules! try_from_entry { .map(|v| v.clone()); let radius_secret = $value - .get_ava_single_radiuscred("radius_secret") - .map(|s| s.to_string()); + .get_ava_single_secret("radius_secret") + .map(str::to_string); let valid_from = $value.get_ava_single_datetime("account_valid_from"); diff --git a/kanidmd/src/lib/lib.rs b/kanidmd/src/lib/lib.rs index 27ddfb5e7..527b6c0c3 100644 --- a/kanidmd/src/lib/lib.rs +++ b/kanidmd/src/lib/lib.rs @@ -1,6 +1,7 @@ //! The Kanidmd server library. This implements all of the internal components of the server //! which is used to process authentication, store identities and enforce access controls. +#![recursion_limit = "512"] #![deny(warnings)] #![warn(unused_extern_crates)] #![deny(clippy::unwrap_used)] @@ -65,10 +66,13 @@ pub mod prelude { pub use crate::utils::duration_from_epoch_now; pub use kanidm_proto::v1::OperationError; pub use smartstring::alias::String as AttrString; + pub use url::Url; pub use uuid::Uuid; pub use crate::audit::AuditScope; pub use crate::constants::*; + pub use crate::filter::{Filter, FilterInvalid}; + pub use crate::entry::{ Entry, EntryCommitted, EntryInit, EntryInvalid, EntryInvalidCommitted, EntryNew, EntryReduced, EntrySealed, EntrySealedCommitted, EntryTuple, EntryValid, diff --git a/kanidmd/src/lib/macros.rs b/kanidmd/src/lib/macros.rs index 009bdac04..f286324a5 100644 --- a/kanidmd/src/lib/macros.rs +++ b/kanidmd/src/lib/macros.rs @@ -21,7 +21,7 @@ macro_rules! setup_test { let be = Backend::new($au, BackendConfig::new_test(), idxmeta, false) .expect("Failed to init BE"); - let qs = QueryServer::new(be, schema_outer); + let qs = QueryServer::new($au, be, schema_outer); qs.initialise_helper($au, duration_from_epoch_now()) .expect("init failed!"); qs @@ -50,7 +50,7 @@ macro_rules! setup_test { let be = Backend::new($au, BackendConfig::new_test(), idxmeta, false) .expect("Failed to init BE"); - let qs = QueryServer::new(be, schema_outer); + let qs = QueryServer::new($au, be, schema_outer); qs.initialise_helper($au, duration_from_epoch_now()) .expect("init failed!"); @@ -96,7 +96,7 @@ macro_rules! run_test_no_init { panic!() } }; - let test_server = QueryServer::new(be, schema_outer); + let test_server = QueryServer::new(&mut audit, be, schema_outer); $test_fn(&test_server, &mut audit); // Any needed teardown? diff --git a/kanidmd/src/lib/modify.rs b/kanidmd/src/lib/modify.rs index 9f6e30c7c..6d554b58a 100644 --- a/kanidmd/src/lib/modify.rs +++ b/kanidmd/src/lib/modify.rs @@ -3,6 +3,7 @@ //! as "states" on what attribute-values should appear as within the `Entry` use crate::prelude::*; +use kanidm_proto::v1::Entry as ProtoEntry; use kanidm_proto::v1::Modify as ProtoModify; use kanidm_proto::v1::ModifyList as ProtoModifyList; @@ -133,6 +134,30 @@ impl ModifyList { } } + pub fn from_patch( + audit: &mut AuditScope, + pe: &ProtoEntry, + qs: &QueryServerWriteTransaction, + ) -> Result { + let mut mods = Vec::new(); + + pe.attrs.iter().try_for_each(|(attr, vals)| { + // Issue a purge to the attr. + mods.push(m_purge(&attr)); + // Now if there are vals, push those too. + // For each value we want to now be present. + vals.iter().try_for_each(|val| { + qs.clone_value(audit, &attr, &val).map(|resolved_v| { + mods.push(Modify::Present(attr.as_str().into(), resolved_v)); + }) + }) + })?; + Ok(ModifyList { + valid: ModifyInvalid, + mods, + }) + } + pub fn validate( &self, schema: &dyn SchemaTransaction, diff --git a/kanidmd/src/lib/plugins/mod.rs b/kanidmd/src/lib/plugins/mod.rs index 747ac9726..cfc3540c4 100644 --- a/kanidmd/src/lib/plugins/mod.rs +++ b/kanidmd/src/lib/plugins/mod.rs @@ -14,6 +14,7 @@ mod domain; mod failure; mod gidnumber; mod memberof; +mod oauth2; mod password_import; mod protected; mod recycle; @@ -273,6 +274,9 @@ impl Plugins { password_import::PasswordImport ) }) + .and_then(|_| { + run_pre_create_transform_plugin!(au, qs, cand, ce, oauth2::Oauth2Secrets) + }) .and_then(|_| { run_pre_create_transform_plugin!(au, qs, cand, ce, gidnumber::GidNumber) }) @@ -334,6 +338,7 @@ impl Plugins { .and_then(|_| { run_pre_modify_plugin!(au, qs, cand, me, password_import::PasswordImport) }) + .and_then(|_| run_pre_modify_plugin!(au, qs, cand, me, oauth2::Oauth2Secrets)) .and_then(|_| run_pre_modify_plugin!(au, qs, cand, me, gidnumber::GidNumber)) .and_then(|_| run_pre_modify_plugin!(au, qs, cand, me, spn::Spn)) // attr unique should always be last diff --git a/kanidmd/src/lib/plugins/oauth2.rs b/kanidmd/src/lib/plugins/oauth2.rs new file mode 100644 index 000000000..dfb6abc2c --- /dev/null +++ b/kanidmd/src/lib/plugins/oauth2.rs @@ -0,0 +1,139 @@ +use crate::event::{CreateEvent, ModifyEvent}; +use crate::plugins::Plugin; +use crate::prelude::*; +use crate::utils::password_from_random; + +lazy_static! { + static ref CLASS_OAUTH2_BASIC: PartialValue = + PartialValue::new_class("oauth2_resource_server_basic"); +} + +pub struct Oauth2Secrets {} + +macro_rules! oauth2_transform { + ( + $au:expr, + $e:expr + ) => {{ + if $e.attribute_equality("class", &CLASS_OAUTH2_BASIC) { + if !$e.attribute_pres("oauth2_rs_basic_secret") { + lsecurity!($au, "regenerating oauth2 basic secret"); + let v = Value::new_utf8(password_from_random()); + $e.add_ava("oauth2_rs_basic_secret", v); + } + if !$e.attribute_pres("oauth2_rs_basic_token_key") { + lsecurity!($au, "regenerating oauth2 token key"); + let k = fernet::Fernet::generate_key(); + let v = Value::new_secret_str(&k); + $e.add_ava("oauth2_rs_basic_token_key", v); + } + } + Ok(()) + }}; +} + +impl Plugin for Oauth2Secrets { + fn id() -> &'static str { + "plugin_oauth2_secrets" + } + + fn pre_create_transform( + au: &mut AuditScope, + _qs: &QueryServerWriteTransaction, + cand: &mut Vec>, + _ce: &CreateEvent, + ) -> Result<(), OperationError> { + cand.iter_mut().try_for_each(|e| oauth2_transform!(au, e)) + } + + fn pre_modify( + au: &mut AuditScope, + _qs: &QueryServerWriteTransaction, + cand: &mut Vec>, + _me: &ModifyEvent, + ) -> Result<(), OperationError> { + cand.iter_mut().try_for_each(|e| oauth2_transform!(au, e)) + } +} + +#[cfg(test)] +mod tests { + use crate::modify::{Modify, ModifyList}; + use crate::prelude::*; + + #[test] + fn test_pre_create_oauth2_secrets() { + let preload: Vec> = Vec::new(); + + let uuid = Uuid::new_v4(); + let e: Entry = entry_init!( + ("class", Value::new_class("object")), + ("class", Value::new_class("oauth2_resource_server")), + ("class", Value::new_class("oauth2_resource_server_basic")), + ("uuid", Value::new_uuid(uuid)), + ("oauth2_rs_name", Value::new_iname("test_resource_server")), + ( + "oauth2_rs_origin", + Value::new_url_s("https://demo.example.com").unwrap() + ) + ); + + let create = vec![e]; + + run_create_test!( + Ok(()), + preload, + create, + None, + |au: &mut AuditScope, qs: &QueryServerWriteTransaction| { + let e = qs + .internal_search_uuid(au, &uuid) + .expect("failed to get oauth2 config"); + assert!(e.attribute_pres("oauth2_rs_basic_secret")); + assert!(e.attribute_pres("oauth2_rs_basic_token_key")); + } + ); + } + + #[test] + fn test_modify_oauth2_secrets_regenerate() { + let uuid = Uuid::new_v4(); + + let e: Entry = entry_init!( + ("class", Value::new_class("object")), + ("class", Value::new_class("oauth2_resource_server")), + ("class", Value::new_class("oauth2_resource_server_basic")), + ("uuid", Value::new_uuid(uuid)), + ("oauth2_rs_name", Value::new_iname("test_resource_server")), + ( + "oauth2_rs_origin", + Value::new_url_s("https://demo.example.com").unwrap() + ), + ("oauth2_rs_basic_secret", Value::new_utf8s("12345")), + ("oauth2_rs_basic_token_key", Value::new_secret_str("12345")) + ); + + let preload = vec![e]; + + run_modify_test!( + Ok(()), + preload, + filter!(f_eq("uuid", PartialValue::new_uuid(uuid))), + ModifyList::new_list(vec![ + Modify::Purged(AttrString::from("oauth2_rs_basic_secret"),), + Modify::Purged(AttrString::from("oauth2_rs_basic_token_key"),) + ]), + None, + |au: &mut AuditScope, qs: &QueryServerWriteTransaction| { + let e = qs + .internal_search_uuid(au, &uuid) + .expect("failed to get oauth2 config"); + assert!(e.attribute_pres("oauth2_rs_basic_secret")); + assert!(e.attribute_pres("oauth2_rs_basic_token_key")); + // Check the values are different. + assert!(e.get_ava_single_str("oauth2_rs_basic_secret") != Some("12345")); + assert!(e.get_ava_single_secret("oauth2_rs_basic_token_key") != Some("12345")); + } + ); + } +} diff --git a/kanidmd/src/lib/schema.rs b/kanidmd/src/lib/schema.rs index 82b96a3ed..132e69f9d 100644 --- a/kanidmd/src/lib/schema.rs +++ b/kanidmd/src/lib/schema.rs @@ -193,7 +193,7 @@ impl SchemaAttribute { SyntaxType::UTF8STRING => v.is_utf8(), SyntaxType::JSON_FILTER => v.is_json_filter(), SyntaxType::Credential => v.is_credential(), - SyntaxType::RadiusUtf8String => v.is_radius_string(), + SyntaxType::SecretUtf8String => v.is_secret_string(), SyntaxType::SshKey => v.is_sshkey(), SyntaxType::SecurityPrincipalName => v.is_spn(), SyntaxType::UINT32 => v.is_uint32(), @@ -201,6 +201,7 @@ impl SchemaAttribute { SyntaxType::NsUniqueId => v.is_nsuniqueid(), SyntaxType::DateTime => v.is_datetime(), SyntaxType::EmailAddress => v.is_email_address(), + SyntaxType::Url => v.is_url(), }; if r { Ok(()) @@ -241,7 +242,7 @@ impl SchemaAttribute { SyntaxType::UTF8STRING => ava.iter().all(Value::is_utf8), SyntaxType::JSON_FILTER => ava.iter().all(Value::is_json_filter), SyntaxType::Credential => ava.iter().all(Value::is_credential), - SyntaxType::RadiusUtf8String => ava.iter().all(Value::is_radius_string), + SyntaxType::SecretUtf8String => ava.iter().all(Value::is_secret_string), SyntaxType::SshKey => ava.iter().all(Value::is_sshkey), SyntaxType::SecurityPrincipalName => ava.iter().all(Value::is_spn), SyntaxType::UINT32 => ava.iter().all(Value::is_uint32), @@ -249,6 +250,7 @@ impl SchemaAttribute { SyntaxType::NsUniqueId => ava.iter().all(Value::is_nsuniqueid), SyntaxType::DateTime => ava.iter().all(Value::is_datetime), SyntaxType::EmailAddress => ava.iter().all(Value::is_email_address), + SyntaxType::Url => ava.iter().all(Value::is_url), }; if valid { Ok(()) diff --git a/kanidmd/src/lib/server.rs b/kanidmd/src/lib/server.rs index fca0cfd0a..278e89768 100644 --- a/kanidmd/src/lib/server.rs +++ b/kanidmd/src/lib/server.rs @@ -47,6 +47,7 @@ lazy_static! { static ref PVCLASS_ACM: PartialValue = PartialValue::new_class("access_control_modify"); static ref PVCLASS_ACC: PartialValue = PartialValue::new_class("access_control_create"); static ref PVCLASS_ACP: PartialValue = PartialValue::new_class("access_control_profile"); + static ref PVCLASS_OAUTH2_RS: PartialValue = PartialValue::new_class("oauth2_resource_server"); static ref PVACP_ENABLE_FALSE: PartialValue = PartialValue::new_bool(false); } @@ -86,6 +87,7 @@ pub struct QueryServerWriteTransaction<'a> { // changing content. changed_schema: Cell, changed_acp: Cell, + changed_oauth2: Cell, // Store the list of changed uuids for other invalidation needs? changed_uuid: Cell>, _db_ticket: SemaphorePermit<'a>, @@ -528,7 +530,7 @@ pub trait QueryServerTransaction<'a> { SyntaxType::JSON_FILTER => Value::new_json_filter(value) .ok_or_else(|| OperationError::InvalidAttribute("Invalid Filter syntax".to_string())), SyntaxType::Credential => Err(OperationError::InvalidAttribute("Credentials can not be supplied through modification - please use the IDM api".to_string())), - SyntaxType::RadiusUtf8String => Err(OperationError::InvalidAttribute("Radius secrets can not be supplied through modification - please use the IDM api".to_string())), + SyntaxType::SecretUtf8String => Err(OperationError::InvalidAttribute("Radius secrets can not be supplied through modification - please use the IDM api".to_string())), SyntaxType::SshKey => Err(OperationError::InvalidAttribute("SSH public keys can not be supplied through modification - please use the IDM api".to_string())), SyntaxType::SecurityPrincipalName => Err(OperationError::InvalidAttribute("SPNs are generated and not able to be set.".to_string())), SyntaxType::UINT32 => Value::new_uint32_str(value) @@ -538,6 +540,8 @@ pub trait QueryServerTransaction<'a> { SyntaxType::DateTime => Value::new_datetime_s(value) .ok_or_else(|| OperationError::InvalidAttribute("Invalid DateTime (rfc3339) syntax".to_string())), SyntaxType::EmailAddress => Ok(Value::new_email_address_s(value)), + SyntaxType::Url => Value::new_url_s(value) + .ok_or_else(|| OperationError::InvalidAttribute("Invalid Url (whatwg/url) syntax".to_string())), } } None => { @@ -611,7 +615,7 @@ pub trait QueryServerTransaction<'a> { }) } SyntaxType::Credential => Ok(PartialValue::new_credential_tag(value)), - SyntaxType::RadiusUtf8String => Ok(PartialValue::new_radius_string()), + SyntaxType::SecretUtf8String => Ok(PartialValue::new_secret_str()), SyntaxType::SshKey => Ok(PartialValue::new_sshkey_tag_s(value)), SyntaxType::SecurityPrincipalName => { PartialValue::new_spn_s(value).ok_or_else(|| { @@ -631,6 +635,11 @@ pub trait QueryServerTransaction<'a> { ) }), SyntaxType::EmailAddress => Ok(PartialValue::new_email_address_s(value)), + SyntaxType::Url => PartialValue::new_url_s(value).ok_or_else(|| { + OperationError::InvalidAttribute( + "Invalid Url (whatwg/url) syntax".to_string(), + ) + }), } } None => { @@ -720,6 +729,19 @@ pub trait QueryServerTransaction<'a> { e }) } + + fn get_oauth2rs_set( + &self, + audit: &mut AuditScope, + ) -> Result, OperationError> { + self.internal_search( + audit, + filter!(f_eq( + "class", + PartialValue::new_class("oauth2_resource_server") + )), + ) + } } // Actually conduct a search request @@ -839,10 +861,14 @@ struct QueryServerMeta { } impl QueryServer { - pub fn new(be: Backend, schema: Schema) -> Self { + pub fn new(audit: &mut AuditScope, be: Backend, schema: Schema) -> Self { let (s_uuid, d_uuid) = { let wr = be.write(); - (wr.get_db_s_uuid(), wr.get_db_d_uuid()) + let res = (wr.get_db_s_uuid(), wr.get_db_d_uuid()); + #[allow(clippy::expect_used)] + wr.commit(audit) + .expect("Critical - unable to commit db_s_uuid or db_d_uuid"); + res }; let pool_size = be.get_pool_size(); @@ -933,6 +959,7 @@ impl QueryServer { accesscontrols: self.accesscontrols.write(), changed_schema: Cell::new(false), changed_acp: Cell::new(false), + changed_oauth2: Cell::new(false), changed_uuid: Cell::new(HashSet::new()), _db_ticket: db_ticket, _write_ticket: write_ticket, @@ -1136,6 +1163,13 @@ impl<'a> QueryServerWriteTransaction<'a> { .any(|e| e.attribute_equality("class", &PVCLASS_ACP)), ) } + if !self.changed_oauth2.get() { + self.changed_oauth2.set( + commit_cand + .iter() + .any(|e| e.attribute_equality("class", &PVCLASS_OAUTH2_RS)), + ) + } let cu = self.changed_uuid.as_ptr(); unsafe { @@ -1143,9 +1177,10 @@ impl<'a> QueryServerWriteTransaction<'a> { } ltrace!( audit, - "Schema reload: {:?}, ACP reload: {:?}", + "Schema reload: {:?}, ACP reload: {:?}, Oauth2 reload: {:?}", self.changed_schema, - self.changed_acp + self.changed_acp, + self.changed_oauth2 ); // We are complete, finalise logging and return @@ -1270,6 +1305,13 @@ impl<'a> QueryServerWriteTransaction<'a> { .any(|e| e.attribute_equality("class", &PVCLASS_ACP)), ) } + if !self.changed_oauth2.get() { + self.changed_oauth2.set( + del_cand + .iter() + .any(|e| e.attribute_equality("class", &PVCLASS_OAUTH2_RS)), + ) + } let cu = self.changed_uuid.as_ptr(); unsafe { @@ -1278,9 +1320,10 @@ impl<'a> QueryServerWriteTransaction<'a> { ltrace!( audit, - "Schema reload: {:?}, ACP reload: {:?}", + "Schema reload: {:?}, ACP reload: {:?}, Oauth2 reload: {:?}", self.changed_schema, - self.changed_acp + self.changed_acp, + self.changed_oauth2, ); // Send result @@ -1640,6 +1683,15 @@ impl<'a> QueryServerWriteTransaction<'a> { .any(|e| e.attribute_equality("class", &PVCLASS_ACP)), ) } + if !self.changed_oauth2.get() { + self.changed_oauth2.set( + norm_cand + .iter() + .chain(pre_candidates.iter()) + .any(|e| e.attribute_equality("class", &PVCLASS_OAUTH2_RS)), + ) + } + let cu = self.changed_uuid.as_ptr(); unsafe { (*cu).extend( @@ -1652,9 +1704,10 @@ impl<'a> QueryServerWriteTransaction<'a> { ltrace!( audit, - "Schema reload: {:?}, ACP reload: {:?}", + "Schema reload: {:?}, ACP reload: {:?}, Oauth2 reload: {:?}", self.changed_schema, - self.changed_acp + self.changed_acp, + self.changed_oauth2, ); // return @@ -1778,6 +1831,13 @@ impl<'a> QueryServerWriteTransaction<'a> { .any(|e| e.attribute_equality("class", &PVCLASS_ACP)), ) } + if !self.changed_oauth2.get() { + self.changed_oauth2.set( + norm_cand + .iter() + .any(|e| e.attribute_equality("class", &PVCLASS_OAUTH2_RS)), + ) + } let cu = self.changed_uuid.as_ptr(); unsafe { (*cu).extend( @@ -1789,9 +1849,10 @@ impl<'a> QueryServerWriteTransaction<'a> { } ltrace!( audit, - "Schema reload: {:?}, ACP reload: {:?}", + "Schema reload: {:?}, ACP reload: {:?}, Oauth2 reload: {:?}", self.changed_schema, - self.changed_acp + self.changed_acp, + self.changed_oauth2, ); ltrace!(audit, "Modify operation success"); @@ -2177,6 +2238,11 @@ impl<'a> QueryServerWriteTransaction<'a> { JSON_SCHEMA_ATTR_UNIX_PASSWORD, JSON_SCHEMA_ATTR_ACCOUNT_EXPIRE, JSON_SCHEMA_ATTR_ACCOUNT_VALID_FROM, + JSON_SCHEMA_ATTR_OAUTH2_RS_NAME, + JSON_SCHEMA_ATTR_OAUTH2_RS_ORIGIN, + JSON_SCHEMA_ATTR_OAUTH2_RS_ACCOUNT_FILTER, + JSON_SCHEMA_ATTR_OAUTH2_RS_BASIC_SECRET, + JSON_SCHEMA_ATTR_OAUTH2_RS_BASIC_TOKEN_KEY, JSON_SCHEMA_CLASS_PERSON, JSON_SCHEMA_CLASS_GROUP, JSON_SCHEMA_CLASS_ACCOUNT, @@ -2184,6 +2250,8 @@ impl<'a> QueryServerWriteTransaction<'a> { JSON_SCHEMA_CLASS_POSIXACCOUNT, JSON_SCHEMA_CLASS_POSIXGROUP, JSON_SCHEMA_CLASS_SYSTEM_CONFIG, + JSON_SCHEMA_CLASS_OAUTH2_RS, + JSON_SCHEMA_CLASS_OAUTH2_RS_BASIC, JSON_SCHEMA_ATTR_NSUNIQUEID, ]; @@ -2272,6 +2340,8 @@ impl<'a> QueryServerWriteTransaction<'a> { JSON_IDM_HP_GROUP_UNIX_EXTEND_PRIV_V1, JSON_IDM_ACP_MANAGE_PRIV_V1, JSON_DOMAIN_ADMINS, + JSON_IDM_HP_OAUTH2_MANAGE_PRIV_V1, + // All members must exist before we write HP JSON_IDM_HIGH_PRIVILEGE_V1, // Built in access controls. JSON_IDM_ADMINS_ACP_RECYCLE_SEARCH_V1, @@ -2305,6 +2375,7 @@ impl<'a> QueryServerWriteTransaction<'a> { JSON_IDM_ACP_PEOPLE_EXTEND_PRIV_V1, JSON_IDM_HP_ACP_ACCOUNT_UNIX_EXTEND_PRIV_V1, JSON_IDM_HP_ACP_GROUP_UNIX_EXTEND_PRIV_V1, + JSON_IDM_HP_ACP_OAUTH2_MANAGE_PRIV_V1, ]; let res: Result<(), _> = idm_entries @@ -2320,6 +2391,9 @@ impl<'a> QueryServerWriteTransaction<'a> { return res; } + self.changed_schema.set(true); + self.changed_acp.set(true); + Ok(()) } @@ -2557,6 +2631,10 @@ impl<'a> QueryServerWriteTransaction<'a> { unsafe { &(*self.changed_uuid.as_ptr()) } } + pub fn get_changed_ouath2(&self) -> bool { + self.changed_oauth2.get() + } + pub fn commit(mut self, audit: &mut AuditScope) -> Result<(), OperationError> { // This could be faster if we cache the set of classes changed // in an operation so we can check if we need to do the reload or not diff --git a/kanidmd/src/lib/value.rs b/kanidmd/src/lib/value.rs index 18bea082f..493681b3c 100644 --- a/kanidmd/src/lib/value.rs +++ b/kanidmd/src/lib/value.rs @@ -16,6 +16,7 @@ use std::fmt; use std::str::FromStr; use std::time::Duration; use time::OffsetDateTime; +use url::Url; use uuid::Uuid; use sshkeys::PublicKey as SshPublicKey; @@ -30,11 +31,11 @@ lazy_static! { }; static ref INAME_RE: Regex = { #[allow(clippy::expect_used)] - Regex::new("^((\\.|_).*|.*(\\s|@|,|/|\\\\|=).*|\\d+|root|nobody|nogroup|wheel|sshd|shadow|systemd.*)$").expect("Invalid Iname regex found") + Regex::new("^((\\.|_).*|.*(\\s|:|;|@|,|/|\\\\|=).*|\\d+|root|nobody|nogroup|wheel|sshd|shadow|systemd.*)$").expect("Invalid Iname regex found") // ^ ^ ^ ^ // | | | \- must not be a reserved name. // | | \- must not be only integers - // | \- must not contain whitespace, @, ',', /, \, = + // | \- must not contain whitespace, @, :, ;, ',', /, \, = // \- must not start with _ or . // Them's be the rules. }; @@ -116,8 +117,6 @@ impl fmt::Display for IndexType { #[allow(non_camel_case_types)] #[derive(Hash, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] pub enum SyntaxType { - // We need an insensitive string type too ... - // We also need to "self host" a syntax type, and index type UTF8STRING, Utf8StringInsensitive, Utf8StringIname, @@ -128,7 +127,7 @@ pub enum SyntaxType { REFERENCE_UUID, JSON_FILTER, Credential, - RadiusUtf8String, + SecretUtf8String, SshKey, SecurityPrincipalName, UINT32, @@ -136,6 +135,7 @@ pub enum SyntaxType { NsUniqueId, DateTime, EmailAddress, + Url, } impl TryFrom<&str> for SyntaxType { @@ -154,7 +154,8 @@ impl TryFrom<&str> for SyntaxType { "REFERENCE_UUID" => Ok(SyntaxType::REFERENCE_UUID), "JSON_FILTER" => Ok(SyntaxType::JSON_FILTER), "CREDENTIAL" => Ok(SyntaxType::Credential), - "RADIUS_UTF8STRING" => Ok(SyntaxType::RadiusUtf8String), + // Compatability for older syntax name. + "RADIUS_UTF8STRING" | "SECRET_UTF8STRING" => Ok(SyntaxType::SecretUtf8String), "SSHKEY" => Ok(SyntaxType::SshKey), "SECURITY_PRINCIPAL_NAME" => Ok(SyntaxType::SecurityPrincipalName), "UINT32" => Ok(SyntaxType::UINT32), @@ -162,6 +163,7 @@ impl TryFrom<&str> for SyntaxType { "NSUNIQUEID" => Ok(SyntaxType::NsUniqueId), "DATETIME" => Ok(SyntaxType::DateTime), "EMAIL_ADDRESS" => Ok(SyntaxType::EmailAddress), + "URL" => Ok(SyntaxType::Url), _ => Err(()), } } @@ -181,7 +183,7 @@ impl TryFrom for SyntaxType { 6 => Ok(SyntaxType::REFERENCE_UUID), 7 => Ok(SyntaxType::JSON_FILTER), 8 => Ok(SyntaxType::Credential), - 9 => Ok(SyntaxType::RadiusUtf8String), + 9 => Ok(SyntaxType::SecretUtf8String), 10 => Ok(SyntaxType::SshKey), 11 => Ok(SyntaxType::SecurityPrincipalName), 12 => Ok(SyntaxType::UINT32), @@ -190,6 +192,7 @@ impl TryFrom for SyntaxType { 15 => Ok(SyntaxType::NsUniqueId), 16 => Ok(SyntaxType::DateTime), 17 => Ok(SyntaxType::EmailAddress), + 18 => Ok(SyntaxType::Url), _ => Err(()), } } @@ -207,7 +210,7 @@ impl SyntaxType { SyntaxType::REFERENCE_UUID => 6, SyntaxType::JSON_FILTER => 7, SyntaxType::Credential => 8, - SyntaxType::RadiusUtf8String => 9, + SyntaxType::SecretUtf8String => 9, SyntaxType::SshKey => 10, SyntaxType::SecurityPrincipalName => 11, SyntaxType::UINT32 => 12, @@ -216,6 +219,7 @@ impl SyntaxType { SyntaxType::NsUniqueId => 15, SyntaxType::DateTime => 16, SyntaxType::EmailAddress => 17, + SyntaxType::Url => 18, } } } @@ -233,7 +237,7 @@ impl fmt::Display for SyntaxType { SyntaxType::REFERENCE_UUID => "REFERENCE_UUID", SyntaxType::JSON_FILTER => "JSON_FILTER", SyntaxType::Credential => "CREDENTIAL", - SyntaxType::RadiusUtf8String => "RADIUS_UTF8STRING", + SyntaxType::SecretUtf8String => "SECRET_UTF8STRING", SyntaxType::SshKey => "SSHKEY", SyntaxType::SecurityPrincipalName => "SECURITY_PRINCIPAL_NAME", SyntaxType::UINT32 => "UINT32", @@ -241,6 +245,7 @@ impl fmt::Display for SyntaxType { SyntaxType::NsUniqueId => "NSUNIQUEID", SyntaxType::DateTime => "DATETIME", SyntaxType::EmailAddress => "EMAIL_ADDRESS", + SyntaxType::Url => "URL", }) } } @@ -249,7 +254,7 @@ impl fmt::Display for SyntaxType { pub enum DataValue { Cred(Credential), SshKey(String), - RadiusCred(String), + SecretValue(String), } impl std::fmt::Debug for DataValue { @@ -257,7 +262,7 @@ impl std::fmt::Debug for DataValue { match self { DataValue::Cred(_) => write!(f, "DataValue::Cred(_)"), DataValue::SshKey(_) => write!(f, "DataValue::SshKey(_)"), - DataValue::RadiusCred(_) => write!(f, "DataValue::RadiusCred(_)"), + DataValue::SecretValue(_) => write!(f, "DataValue::SecretValue(_)"), } } } @@ -284,13 +289,14 @@ pub enum PartialValue { // Tag, matches to a DataValue. Cred(String), SshKey(String), - RadiusCred, + SecretValue, Spn(String, String), Uint32(u32), Cid(Cid), Nsuniqueid(String), DateTime(OffsetDateTime), EmailAddress(String), + Url(Url), } impl PartialValue { @@ -431,12 +437,12 @@ impl PartialValue { matches!(self, PartialValue::Cred(_)) } - pub fn new_radius_string() -> Self { - PartialValue::RadiusCred + pub fn new_secret_str() -> Self { + PartialValue::SecretValue } - pub fn is_radius_string(&self) -> bool { - matches!(self, PartialValue::RadiusCred) + pub fn is_secret_string(&self) -> bool { + matches!(self, PartialValue::SecretValue) } pub fn new_sshkey_tag(s: String) -> Self { @@ -528,6 +534,14 @@ impl PartialValue { matches!(self, PartialValue::EmailAddress(_)) } + pub fn new_url_s(s: &str) -> Option { + Url::parse(s).ok().map(PartialValue::Url) + } + + pub fn is_url(&self) -> bool { + matches!(self, PartialValue::Url(_)) + } + pub fn to_str(&self) -> Option<&str> { match self { PartialValue::Utf8(s) => Some(s.as_str()), @@ -537,6 +551,13 @@ impl PartialValue { } } + pub fn to_url(&self) -> Option<&Url> { + match self { + PartialValue::Url(u) => Some(&u), + _ => None, + } + } + pub fn contains(&self, s: &PartialValue) -> bool { match (self, s) { (PartialValue::Utf8(s1), PartialValue::Utf8(s2)) => s1.contains(s2), @@ -572,7 +593,7 @@ impl PartialValue { } PartialValue::Cred(tag) => tag.to_string(), // This will never match as we never index radius creds! See generate_idx_eq_keys - PartialValue::RadiusCred => "_".to_string(), + PartialValue::SecretValue => "_".to_string(), PartialValue::SshKey(tag) => tag.to_string(), PartialValue::Spn(name, realm) => format!("{}@{}", name, realm), PartialValue::Uint32(u) => u.to_string(), @@ -582,6 +603,7 @@ impl PartialValue { debug_assert!(odt.offset() == time::UtcOffset::UTC); odt.format(time::Format::Rfc3339) } + PartialValue::Url(u) => u.to_string(), } } @@ -896,22 +918,22 @@ impl Value { } } - pub fn new_radius_str(cleartext: &str) -> Self { + pub fn new_secret_str(cleartext: &str) -> Self { Value { - pv: PartialValue::new_radius_string(), - data: Some(Box::new(DataValue::RadiusCred(cleartext.to_string()))), + pv: PartialValue::new_secret_str(), + data: Some(Box::new(DataValue::SecretValue(cleartext.to_string()))), } } - pub fn is_radius_string(&self) -> bool { - matches!(&self.pv, PartialValue::RadiusCred) + pub fn is_secret_string(&self) -> bool { + matches!(&self.pv, PartialValue::SecretValue) } - pub fn get_radius_secret(&self) -> Option<&str> { + pub fn get_secret_str(&self) -> Option<&str> { match &self.pv { - PartialValue::RadiusCred => match &self.data { + PartialValue::SecretValue => match &self.data { Some(dv) => match dv.as_ref() { - DataValue::RadiusCred(c) => Some(c.as_str()), + DataValue::SecretValue(c) => Some(c.as_str()), _ => None, }, _ => None, @@ -1042,6 +1064,14 @@ impl Value { self.pv.is_email_address() } + pub fn new_url_s(s: &str) -> Option { + PartialValue::new_url_s(s).map(|pv| Value { pv, data: None }) + } + + pub fn is_url(&self) -> bool { + self.pv.is_url() + } + pub fn contains(&self, s: &PartialValue) -> bool { self.pv.contains(s) } @@ -1107,8 +1137,8 @@ impl Value { }) } DbValueV1::SecretValue(d) => Ok(Value { - pv: PartialValue::RadiusCred, - data: Some(Box::new(DataValue::RadiusCred(d))), + pv: PartialValue::SecretValue, + data: Some(Box::new(DataValue::SecretValue(d))), }), DbValueV1::SshKey(ts) => Ok(Value { pv: PartialValue::SshKey(ts.tag), @@ -1141,6 +1171,10 @@ impl Value { pv: PartialValue::EmailAddress(email_addr), data: None, }), + DbValueV1::Url(u) => Ok(Value { + pv: PartialValue::Url(u), + data: None, + }), } } @@ -1177,10 +1211,10 @@ impl Value { data: c.to_db_valuev1(), }) } - PartialValue::RadiusCred => { + PartialValue::SecretValue => { let ru = match &self.data { Some(v) => match v.as_ref() { - DataValue::RadiusCred(rc) => rc.clone(), + DataValue::SecretValue(rc) => rc.clone(), _ => unreachable!(), }, None => unreachable!(), @@ -1215,6 +1249,7 @@ impl Value { PartialValue::EmailAddress(mail) => { DbValueV1::EmailAddress(DbValueEmailAddressV1 { d: mail.clone() }) } + PartialValue::Url(u) => DbValueV1::Url(u.clone()), } } @@ -1227,6 +1262,13 @@ impl Value { } } + pub fn to_url(&self) -> Option<&Url> { + match &self.pv { + PartialValue::Url(u) => Some(&u), + _ => None, + } + } + pub fn as_string(&self) -> Option<&String> { match &self.pv { PartialValue::Utf8(s) => Some(s), @@ -1338,9 +1380,9 @@ impl Value { }, None => format!("{}: corrupted value", tag), }, - // We don't disclose the radius credential unless by special + // We don't disclose the secret value unless by special // interfaces. - PartialValue::RadiusCred => "radius".to_string(), + PartialValue::SecretValue => "secret".to_string(), PartialValue::Spn(n, r) => format!("{}@{}", n, r), PartialValue::Uint32(u) => u.to_string(), PartialValue::Cid(c) => format!("{:?}_{}_{}", c.ts, c.d_uuid, c.s_uuid), @@ -1348,6 +1390,7 @@ impl Value { debug_assert!(odt.offset() == time::UtcOffset::UTC); odt.format(time::Format::Rfc3339) } + PartialValue::Url(u) => u.to_string(), } } @@ -1377,13 +1420,14 @@ impl Value { }, None => false, }, - PartialValue::RadiusCred => match &self.data { - Some(v) => matches!(v.as_ref(), DataValue::RadiusCred(_)), + PartialValue::SecretValue => match &self.data { + Some(v) => matches!(v.as_ref(), DataValue::SecretValue(_)), None => false, }, PartialValue::Nsuniqueid(s) => NSUNIQUEID_RE.is_match(s), PartialValue::DateTime(odt) => odt.offset() == time::UtcOffset::UTC, PartialValue::EmailAddress(mail) => validator::validate_email(mail.as_str()), + // PartialValue::Url validated through parsing. _ => true, } } @@ -1409,7 +1453,7 @@ impl Value { // Should this also extract the key data? vec![tag.to_string()] } - PartialValue::RadiusCred => vec![], + PartialValue::SecretValue => vec![], PartialValue::Spn(n, r) => vec![format!("{}@{}", n, r)], PartialValue::Uint32(u) => vec![u.to_string()], PartialValue::Cid(_) => vec![], @@ -1417,6 +1461,7 @@ impl Value { debug_assert!(odt.offset() == time::UtcOffset::UTC); vec![odt.format(time::Format::Rfc3339)] } + PartialValue::Url(u) => vec![u.to_string()], } } } @@ -1652,6 +1697,22 @@ mod tests { assert!(val3.validate()); } + #[test] + fn test_value_url() { + // https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address + let val1 = Value::new_url_s("https://localhost:8000/search?q=text#hello"); + let val2 = Value::new_url_s("https://github.com/kanidm/kanidm"); + let val3 = Value::new_url_s("ldap://foo.com"); + let inv1 = Value::new_url_s("127.0."); + let inv2 = Value::new_url_s("🤔"); + + assert!(inv1.is_none()); + assert!(inv2.is_none()); + assert!(val1.is_some()); + assert!(val2.is_some()); + assert!(val3.is_some()); + } + /* #[test] fn test_schema_syntax_json_filter() {