diff --git a/kanidm_book/src/oauth2.md b/kanidm_book/src/oauth2.md index 10081ad64..240bfb7a1 100644 --- a/kanidm_book/src/oauth2.md +++ b/kanidm_book/src/oauth2.md @@ -67,7 +67,7 @@ Kanidm will expose it's oauth2 apis at the urls: For an authorisation to proceed, the resource server will request a list of scopes, which are unique to that resource server. For example, when a user wishes to login to the admin panel of the resource server, it may request the "admin" scope from kanidm for authorisation. But when -a user wants to login, it may only request "acces" as a scope from kanidm. +a user wants to login, it may only request "access" as a scope from kanidm. As each resource server may have it's own scopes and understanding of these, Kanidm isolates scopes to each resource server connected to Kanidm. Kanidm has two methods of granting scopes to accounts (users). diff --git a/kanidm_client/tests/oauth2_test.rs b/kanidm_client/tests/oauth2_test.rs index cd88c766e..d2034b815 100644 --- a/kanidm_client/tests/oauth2_test.rs +++ b/kanidm_client/tests/oauth2_test.rs @@ -2,7 +2,10 @@ mod common; use crate::common::{run_test, ADMIN_TEST_PASSWORD}; use kanidm_client::KanidmClient; -use kanidm_proto::oauth2::{AccessTokenRequest, AccessTokenResponse, ConsentRequest}; +use kanidm_proto::oauth2::{ + AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, + AccessTokenResponse, ConsentRequest, +}; use oauth2_ext::PkceCodeChallenge; use std::collections::HashMap; use url::Url; @@ -169,7 +172,7 @@ fn test_oauth2_basic_flow() { let response = client .post(format!("{}/oauth2/token", url)) - .basic_auth("test_integration", Some(client_secret)) + .basic_auth("test_integration", Some(client_secret.clone())) .form(&form_req) .send() .await @@ -180,12 +183,45 @@ fn test_oauth2_basic_flow() { // The body is a json AccessTokenResponse - let _atr = response + let atr = response .json::() .await .expect("Unable to decode AccessTokenResponse"); // Step 4 - inspect the granted token. + let intr_request = AccessTokenIntrospectRequest { + token: atr.access_token.clone(), + token_type_hint: None, + }; + + let response = client + .post(format!("{}/oauth2/token/introspect", url)) + .basic_auth("test_integration", Some(client_secret)) + .form(&intr_request) + .send() + .await + .expect("Failed to send token introspect request."); + + assert!(response.status() == reqwest::StatusCode::OK); + assert_no_cache!(response); + + let tir = response + .json::() + .await + .expect("Unable to decode AccessTokenIntrospectResponse"); + + assert!(tir.active); + assert!(tir.scope.is_some()); + assert!(tir.client_id.as_deref() == Some("test_integration")); + assert!(tir.username.as_deref() == Some("admin@example.com")); + assert!(tir.token_type.as_deref() == Some("access_token")); + assert!(tir.exp.is_some()); + assert!(tir.iat.is_some()); + assert!(tir.nbf.is_some()); + assert!(tir.sub.is_some()); + assert!(tir.aud.is_none()); + assert!(tir.iss.is_none()); + assert!(tir.jti.is_none()); }) }) } diff --git a/kanidm_proto/src/oauth2.rs b/kanidm_proto/src/oauth2.rs index 5cef8c2b4..9cc3935f8 100644 --- a/kanidm_proto/src/oauth2.rs +++ b/kanidm_proto/src/oauth2.rs @@ -78,6 +78,60 @@ pub struct AccessTokenResponse { pub scope: Option, } +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessTokenIntrospectRequest { + pub token: String, + /// https://datatracker.ietf.org/doc/html/rfc7009#section-4.1.2 + #[serde(skip_serializing_if = "Option::is_none")] + pub token_type_hint: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct AccessTokenIntrospectResponse { + pub active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, +} + +impl AccessTokenIntrospectResponse { + pub fn inactive() -> Self { + AccessTokenIntrospectResponse { + active: false, + scope: None, + client_id: None, + username: None, + token_type: None, + exp: None, + iat: None, + nbf: None, + sub: None, + aud: None, + iss: None, + jti: None, + } + } +} + #[derive(Serialize, Deserialize, Debug)] pub struct ErrorResponse { pub error: String, diff --git a/kanidmd/src/lib/actors/v1_read.rs b/kanidmd/src/lib/actors/v1_read.rs index 107b3037e..c5f99a403 100644 --- a/kanidmd/src/lib/actors/v1_read.rs +++ b/kanidmd/src/lib/actors/v1_read.rs @@ -18,8 +18,8 @@ use kanidm_proto::v1::{BackupCodesView, OperationError, RadiusAuthToken}; use crate::filter::{Filter, FilterInvalid}; use crate::idm::oauth2::{ - AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, - ConsentRequest, Oauth2Error, + AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, + AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, ConsentRequest, Oauth2Error, }; use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::ldap::{LdapBoundToken, LdapResponseState, LdapServer}; @@ -1053,6 +1053,27 @@ impl QueryServerReadV1 { res } + #[instrument( + level = "trace", + name = "oauth2_token_introspect", + skip(self, client_authz, intr_req, eventid) + fields(uuid = ?eventid) + )] + pub async fn handle_oauth2_token_introspect( + &self, + client_authz: String, + intr_req: AccessTokenIntrospectRequest, + eventid: Uuid, + ) -> Result { + let ct = duration_from_epoch_now(); + let idms_prox_read = self.idms.proxy_read_async().await; + let res = spanned!("actors::v1_read::handle", { + // Now we can send to the idm server for introspection checking. + idms_prox_read.check_oauth2_token_introspect(&client_authz, &intr_req, ct) + }); + res + } + #[instrument( level = "trace", name = "auth_valid", diff --git a/kanidmd/src/lib/core/https/mod.rs b/kanidmd/src/lib/core/https/mod.rs index b31232a7b..65f2c7534 100644 --- a/kanidmd/src/lib/core/https/mod.rs +++ b/kanidmd/src/lib/core/https/mod.rs @@ -399,11 +399,9 @@ pub fn create_https_server( .post(oauth2_authorise_permit_post) .get(oauth2_authorise_permit_get); oauth2_process.at("/token").post(oauth2_token_post); - /* oauth2_process .at("/token/introspect") - .get(oauth2_token_introspect_get); - */ + .post(oauth2_token_introspect_post); let mut raw_route = appserver.at("/v1/raw"); raw_route.at("/create").post(create); diff --git a/kanidmd/src/lib/core/https/oauth2.rs b/kanidmd/src/lib/core/https/oauth2.rs index 090985e88..e4f5e9b97 100644 --- a/kanidmd/src/lib/core/https/oauth2.rs +++ b/kanidmd/src/lib/core/https/oauth2.rs @@ -1,7 +1,8 @@ use super::v1::{json_rest_event_get, json_rest_event_post}; use super::{to_tide_response, AppState, RequestExtensions}; use crate::idm::oauth2::{ - AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, ErrorResponse, Oauth2Error, + AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, + ErrorResponse, Oauth2Error, }; use crate::prelude::*; use kanidm_proto::v1::Entry as ProtoEntry; @@ -401,7 +402,70 @@ pub async fn get_openid_configuration(_req: tide::Request) -> tide::Re Ok(res) } -/* -pub async fn oauth2_token_introspect_get(req: tide::Request) -> tide::Result { +pub async fn oauth2_token_introspect_post(mut req: tide::Request) -> tide::Result { + // This is called directly by the resource server, where we then issue + // information about this token to the caller. + let (eventid, hvalue) = req.new_eventid(); + + 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 introspection request, could we accept json or form? Prob needs content type here. + let intr_req: AccessTokenIntrospectRequest = req.body_form().await.map_err(|e| { + request_error!("{:?}", e); + tide::Error::from_str( + tide::StatusCode::BadRequest, + "Invalid Oauth2 AccessTokenIntrospectRequest", + ) + })?; + + request_trace!("Introspect Request - {:?}", intr_req); + + let res = req + .state() + .qe_r_ref + .handle_oauth2_token_introspect(client_authz, intr_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("X-KANIDM-OPID", hvalue); + res + }) } -*/ diff --git a/kanidmd/src/lib/idm/oauth2.rs b/kanidmd/src/lib/idm/oauth2.rs index ca321d3ba..7499f613b 100644 --- a/kanidmd/src/lib/idm/oauth2.rs +++ b/kanidmd/src/lib/idm/oauth2.rs @@ -6,6 +6,7 @@ //! use crate::identity::IdentityId; +use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerTransaction}; use crate::prelude::*; use concread::cowcell::*; use fernet::Fernet; @@ -13,6 +14,7 @@ use hashbrown::HashMap; use kanidm_proto::v1::UserAuthToken; use openssl::sha; use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::sync::Arc; use time::OffsetDateTime; use tracing::trace; @@ -20,8 +22,8 @@ use url::{Origin, Url}; use webauthn_rs::base64_data::Base64UrlSafeData; pub use kanidm_proto::oauth2::{ - AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, - ConsentRequest, ErrorResponse, + AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, + AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ConsentRequest, ErrorResponse, }; use std::convert::TryFrom; @@ -82,8 +84,6 @@ struct ConsentToken { pub scopes: Vec, } -// consent token? - #[derive(Serialize, Deserialize, Debug)] struct TokenExchangeCode { // We don't need the client_id here, because it's signed with an RS specific @@ -97,6 +97,32 @@ struct TokenExchangeCode { pub scopes: Vec, } +#[derive(Serialize, Deserialize, Debug)] +enum Oauth2TokenType { + Access, + Refresh, +} + +impl fmt::Display for Oauth2TokenType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Oauth2TokenType::Access => write!(f, "access_token"), + Oauth2TokenType::Refresh => write!(f, "refresh_token"), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +struct Oauth2UserToken { + pub toktype: Oauth2TokenType, + pub uat: UserAuthToken, + pub scopes: Vec, + // Oauth2 exp is seperate to uat expiry + pub exp: i64, + pub iat: i64, + pub nbf: i64, +} + // consentPermitResponse #[derive(Debug)] @@ -476,34 +502,10 @@ impl Oauth2ResourceServersReadTransaction { return Err(Oauth2Error::InvalidRequest); } - // Check the client_authz - let authz = base64::decode(&client_authz) - .map_err(|_| { - admin_error!("Basic authz invalid base64"); - Oauth2Error::AuthenticationRequired - }) - .and_then(|data| { - String::from_utf8(data).map_err(|_| { - admin_error!("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(|| { - admin_error!("Basic authz invalid format (corrupt input?)"); - Oauth2Error::AuthenticationRequired - })?; - let secret = split_iter.next().ok_or_else(|| { - admin_error!("Basic authz invalid format (missing ':' seperator?)"); - Oauth2Error::AuthenticationRequired - })?; + let (client_id, secret) = parse_basic_authz(client_authz)?; // Get the o2rs for the handle. - let o2rs = self.inner.rs_set.get(client_id).ok_or_else(|| { + let o2rs = self.inner.rs_set.get(&client_id).ok_or_else(|| { admin_warn!("Invalid oauth2 client_id"); Oauth2Error::AuthenticationRequired })?; @@ -568,8 +570,20 @@ impl Oauth2ResourceServersReadTransaction { Some(code_xchg.scopes.join(" ")) }; + let iat = ct.as_secs() as i64; + + let o2uat = Oauth2UserToken { + toktype: Oauth2TokenType::Access, + uat: code_xchg.uat, + scopes: code_xchg.scopes, + iat, + nbf: iat, + // TODO: Make configurable! + exp: iat + 480, + }; + // If we are type == Uat, then we re-use the same encryption material here. - let access_token_data = serde_json::to_vec(&code_xchg.uat).map_err(|e| { + let access_token_data = serde_json::to_vec(&o2uat).map_err(|e| { admin_error!(err = ?e, "Unable to encode uat data"); Oauth2Error::ServerError(OperationError::SerdeJsonError) })?; @@ -588,6 +602,110 @@ impl Oauth2ResourceServersReadTransaction { scope, }) } + + pub fn check_oauth2_token_introspect( + &self, + idms: &IdmServerProxyReadTransaction<'_>, + client_authz: &str, + intr_req: &AccessTokenIntrospectRequest, + ct: Duration, + ) -> Result { + let (client_id, secret) = parse_basic_authz(client_authz)?; + + // Get the o2rs for the handle. + let o2rs = self.inner.rs_set.get(&client_id).ok_or_else(|| { + admin_warn!("Invalid oauth2 client_id"); + Oauth2Error::AuthenticationRequired + })?; + + // check the secret. + if o2rs.authz_secret != secret { + security_info!("Invalid oauth2 client_id secret"); + return Err(Oauth2Error::AuthenticationRequired); + } + // We are authenticated! Yay! Now we can actually check things ... + + // In these cases instead of error, we need to return Active:false + // Check the Token is correctly signed. + let access_token: Result = o2rs + .token_fernet + .decrypt_at_time(&intr_req.token, None, ct.as_secs()) + .map_err(|_| { + admin_error!("Failed to decrypt access token introspect request"); + Oauth2Error::InvalidRequest + }) + .and_then(|data| { + serde_json::from_slice(&data).map_err(|e| { + admin_error!("Failed to deserialise access token - {:?}", e); + Oauth2Error::InvalidRequest + }) + }); + + let access_token = match access_token { + Ok(a) => a, + Err(_) => return Ok(AccessTokenIntrospectResponse::inactive()), + }; + + let valid = idms + .check_uat_valid(&access_token.uat, ct) + .map_err(|_| admin_error!("Account is not valid")); + + match valid { + Ok(true) => {} + _ => return Ok(AccessTokenIntrospectResponse::inactive()), + }; + + let scope = if access_token.scopes.is_empty() { + None + } else { + Some(access_token.scopes.join(" ")) + }; + + Ok(AccessTokenIntrospectResponse { + active: true, + scope, + client_id: Some(client_id), + username: Some(access_token.uat.spn.clone()), + token_type: Some(access_token.toktype.to_string()), + exp: Some(access_token.exp), + iat: Some(access_token.iat), + nbf: Some(access_token.nbf), + sub: Some(access_token.uat.uuid.to_string()), + aud: None, + iss: None, + jti: None, + }) + } +} + +fn parse_basic_authz(client_authz: &str) -> Result<(String, String), Oauth2Error> { + // Check the client_authz + let authz = base64::decode(&client_authz) + .map_err(|_| { + admin_error!("Basic authz invalid base64"); + Oauth2Error::AuthenticationRequired + }) + .and_then(|data| { + String::from_utf8(data).map_err(|_| { + admin_error!("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(|| { + admin_error!("Basic authz invalid format (corrupt input?)"); + Oauth2Error::AuthenticationRequired + })?; + let secret = split_iter.next().ok_or_else(|| { + admin_error!("Basic authz invalid format (missing ':' seperator?)"); + Oauth2Error::AuthenticationRequired + })?; + + Ok((client_id.to_string(), secret.to_string())) } #[cfg(test)] @@ -597,6 +715,9 @@ mod tests { use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::prelude::*; + use crate::event::ModifyEvent; + use crate::modify::{Modify, ModifyList}; + use kanidm_proto::oauth2::*; use kanidm_proto::v1::{AuthType, UserAuthToken}; use webauthn_rs::base64_data::Base64UrlSafeData; @@ -1102,4 +1223,83 @@ mod tests { ); }) } + + #[test] + fn test_idm_oauth2_token_introspect() { + run_idm_test!(|_qs: &QueryServer, + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, uat, ident) = setup_oauth2_resource_server(idms, ct); + let client_authz = base64::encode(format!("test_resource_server:{}", secret)); + + 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!(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(&ident, &uat, &consent_request.consent_token, ct) + .expect("Failed to perform oauth2 permit"); + + 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, + code_verifier, + }; + let oauth2_token = idms_prox_read + .check_oauth2_token_exchange(&client_authz, &token_req, ct) + .expect("Unable to exchange for oauth2 token"); + + // Okay, now we have the token, we can check it works with introspect. + let intr_request = AccessTokenIntrospectRequest { + token: oauth2_token.access_token.clone(), + token_type_hint: None, + }; + let intr_response = idms_prox_read + .check_oauth2_token_introspect(&client_authz, &intr_request, ct) + .expect("Failed to inspect token"); + + assert!(intr_response.active); + assert!(intr_response.scope.as_deref() == Some("test")); + assert!(intr_response.client_id.as_deref() == Some("test_resource_server")); + assert!(intr_response.username.as_deref() == Some("admin@example.com")); + assert!(intr_response.token_type.as_deref() == Some("access_token")); + assert!(intr_response.iat == Some(ct.as_secs() as i64)); + assert!(intr_response.nbf == Some(ct.as_secs() as i64)); + + drop(idms_prox_read); + // start a write, + + let idms_prox_write = idms.proxy_write(ct); + // Expire the account, should cause introspect to return inactive. + let v_expire = Value::new_datetime_epoch(Duration::from_secs(TEST_CURRENT_TIME - 1)); + let me_inv_m = unsafe { + ModifyEvent::new_internal_invalid( + filter!(f_eq("name", PartialValue::new_iname("admin"))), + ModifyList::new_list(vec![Modify::Present( + AttrString::from("account_expire"), + v_expire, + )]), + ) + }; + // go! + assert!(idms_prox_write.qs_write.modify(&me_inv_m).is_ok()); + assert!(idms_prox_write.commit().is_ok()); + + // start a new read + // check again. + let idms_prox_read = idms.proxy_read(); + let intr_response = idms_prox_read + .check_oauth2_token_introspect(&client_authz, &intr_request, ct) + .expect("Failed to inspect token"); + + assert!(!intr_response.active); + }) + } } diff --git a/kanidmd/src/lib/idm/server.rs b/kanidmd/src/lib/idm/server.rs index de92bbb40..e70adcf09 100644 --- a/kanidmd/src/lib/idm/server.rs +++ b/kanidmd/src/lib/idm/server.rs @@ -15,8 +15,9 @@ use crate::idm::event::{ }; use crate::idm::mfareg::{MfaRegCred, MfaRegNext, MfaRegSession}; use crate::idm::oauth2::{ - AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, - ConsentRequest, Oauth2Error, Oauth2ResourceServers, Oauth2ResourceServersReadTransaction, + AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, + AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, ConsentRequest, Oauth2Error, + Oauth2ResourceServers, Oauth2ResourceServersReadTransaction, Oauth2ResourceServersWriteTransaction, }; use crate::idm::radius::RadiusAccount; @@ -346,7 +347,6 @@ pub trait IdmServerTransaction<'a> { fn get_uat_bundy_txn(&self) -> &HS512; - // ! TRACING INTEGRATED fn validate_and_parse_uat( &self, token: Option<&str>, @@ -373,7 +373,22 @@ pub trait IdmServerTransaction<'a> { } } - // ! TRACING INTEGRATED + fn check_uat_valid(&self, uat: &UserAuthToken, ct: Duration) -> Result { + let entry = self + .get_qs_txn() + .internal_search_uuid(&uat.uuid) + .map_err(|e| { + admin_error!(?e, "from_ro_uat failed"); + e + })?; + + Ok(Account::check_within_valid_time( + ct, + entry.get_ava_single_datetime("account_valid_from").as_ref(), + entry.get_ava_single_datetime("account_expire").as_ref(), + )) + } + fn process_uat_to_identity( &self, uat: &UserAuthToken, @@ -1076,6 +1091,16 @@ impl<'a> IdmServerProxyReadTransaction<'a> { self.oauth2rs .check_oauth2_token_exchange(client_authz, token_req, ct) } + + pub fn check_oauth2_token_introspect( + &self, + client_authz: &str, + intr_req: &AccessTokenIntrospectRequest, + ct: Duration, + ) -> Result { + self.oauth2rs + .check_oauth2_token_introspect(self, client_authz, intr_req, ct) + } } impl<'a> IdmServerTransaction<'a> for IdmServerProxyWriteTransaction<'a> {