diff --git a/kanidm_proto/src/oauth2.rs b/kanidm_proto/src/oauth2.rs index bcfb1cafa..26afdb8df 100644 --- a/kanidm_proto/src/oauth2.rs +++ b/kanidm_proto/src/oauth2.rs @@ -57,18 +57,23 @@ pub struct AuthorisationRequestOidc { pub acr: Option, } -/// We ask our user to consent to this Authorisation Request with the -/// following data. +/// When we request to authorise, it can either prompt us for consent, +/// or it can immediately be granted due the past grant. #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ConsentRequest { - // A pretty-name of the client - pub client_name: String, - // A list of scopes requested / to be issued. - 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, +pub enum AuthorisationResponse { + ConsentRequested { + // A pretty-name of the client + client_name: String, + // A list of scopes requested / to be issued. + scopes: Vec, + // Extra PII that may be requested + pii_scopes: Vec, + // The users displayname (?) + // pub display_name: String, + // The token we need to be given back to allow this to proceed + consent_token: String, + }, + Permitted, } // The resource server then contacts the token endpoint with diff --git a/kanidmd/Dockerfile b/kanidmd/Dockerfile index 247dc1efb..16a053283 100644 --- a/kanidmd/Dockerfile +++ b/kanidmd/Dockerfile @@ -11,8 +11,8 @@ LABEL maintainer william@blackhats.net.au RUN zypper install -y \ cargo \ - rust \ - gcc clang lld \ + rust wasm-pack \ + clang lld \ make automake autoconf \ libopenssl-devel pam-devel \ sqlite3-devel \ @@ -20,7 +20,6 @@ RUN zypper install -y \ zypper clean -a COPY . /usr/src/kanidm -WORKDIR /usr/src/kanidm/kanidmd/daemon ARG SCCACHE_REDIS="" ARG KANIDM_FEATURES @@ -31,25 +30,38 @@ RUN mkdir /scratch RUN echo $KANIDM_BUILD_PROFILE RUN echo $KANIDM_FEATURES -ENV RUSTFLAGS="-Clinker=clang -Clink-arg=-fuse-ld=/usr/bin/ld.lld" ENV CARGO_HOME=/scratch/.cargo +ENV RUSTFLAGS="-Clinker=clang" + +WORKDIR /usr/src/kanidm/kanidmd_web_ui +RUN if [ "${SCCACHE_REDIS}" != "" ]; \ + then \ + export CARGO_INCREMENTAL=false && \ + export RUSTC_WRAPPER=sccache && \ + sccache --start-server; \ + fi && \ + ./build_wasm_dev.sh + +WORKDIR /usr/src/kanidm/kanidmd/daemon + +ENV RUSTFLAGS="-Clinker=clang -Clink-arg=-fuse-ld=/usr/bin/ld.lld" RUN if [ "${SCCACHE_REDIS}" != "" ]; \ - then \ - export CC="/usr/bin/sccache /usr/bin/clang" && \ - export CARGO_INCREMENTAL=false && \ - export RUSTC_WRAPPER=sccache && \ - sccache --start-server; \ - else \ - export CC="/usr/bin/clang"; \ - fi && \ + then \ + export CC="/usr/bin/sccache /usr/bin/clang" && \ + export CARGO_INCREMENTAL=false && \ + export RUSTC_WRAPPER=sccache && \ + sccache --start-server; \ + else \ + export CC="/usr/bin/clang"; \ + fi && \ cargo build ${KANIDM_BUILD_OPTIONS} \ - --features=${KANIDM_FEATURES} \ - --target-dir=/usr/src/kanidm/target/ \ - --release && \ + --features=${KANIDM_FEATURES} \ + --target-dir=/usr/src/kanidm/target/ \ + --release && \ if [ "${SCCACHE_REDIS}" != "" ]; \ - then sccache -s; \ - fi; + then sccache -s; \ + fi; RUN ls -al /usr/src/kanidm/target/release diff --git a/kanidmd/daemon/Cargo.toml b/kanidmd/daemon/Cargo.toml index 640ddf4cc..3e4d1ab6e 100644 --- a/kanidmd/daemon/Cargo.toml +++ b/kanidmd/daemon/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" [dependencies] kanidm = { path = "../idm" } score = { path = "../score" } -clap = { version = "^3.2", features = ["derive"] } +clap = { version = "^3.2", features = ["derive", "env"] } users = "^0.11.0" serde = { version = "^1.0.137", features = ["derive"] } tokio = { version = "^1.19.1", features = ["rt-multi-thread", "macros", "signal"] } diff --git a/kanidmd/idm/src/actors/v1_read.rs b/kanidmd/idm/src/actors/v1_read.rs index 7ef96343f..04bc62347 100644 --- a/kanidmd/idm/src/actors/v1_read.rs +++ b/kanidmd/idm/src/actors/v1_read.rs @@ -20,8 +20,8 @@ use kanidm_proto::v1::{BackupCodesView, OperationError, RadiusAuthToken}; use crate::filter::{Filter, FilterInvalid}; use crate::idm::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, - AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, ConsentRequest, JwkKeySet, - Oauth2Error, OidcDiscoveryResponse, OidcToken, + AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, + JwkKeySet, Oauth2Error, OidcDiscoveryResponse, OidcToken, }; use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::ldap::{LdapBoundToken, LdapResponseState, LdapServer}; @@ -1120,7 +1120,7 @@ impl QueryServerReadV1 { uat: Option, auth_req: AuthorisationRequest, eventid: Uuid, - ) -> Result { + ) -> Result { let ct = duration_from_epoch_now(); let idms_prox_read = self.idms.proxy_read_async().await; let res = spanned!("actors::v1_read::handle", { diff --git a/kanidmd/idm/src/constants/schema.rs b/kanidmd/idm/src/constants/schema.rs index c5d02e5ec..a8bbd92c0 100644 --- a/kanidmd/idm/src/constants/schema.rs +++ b/kanidmd/idm/src/constants/schema.rs @@ -718,6 +718,37 @@ pub const JSON_SCHEMA_ATTR_OAUTH2_RS_IMPLICIT_SCOPES: &str = r#"{ } }"#; +pub const JSON_SCHEMA_ATTR_OAUTH2_CONSENT_SCOPE_MAP: &str = r#"{ + "attrs": { + "class": [ + "object", + "system", + "attributetype" + ], + "description": [ + "A set of scopes mapped from a relying server to a user, where the user has previously consented to the following. If changed or deleted, consent will be re-sought." + ], + "index": [ + "EQUALITY" + ], + "unique": [ + "false" + ], + "multivalue": [ + "true" + ], + "attributename": [ + "oauth2_consent_scope_map" + ], + "syntax": [ + "OAUTH_SCOPE_MAP" + ], + "uuid": [ + "00000000-0000-0000-0000-ffff00000097" + ] + } +}"#; + pub const JSON_SCHEMA_ATTR_ES256_PRIVATE_KEY_DER: &str = r#"{ "attrs": { "class": [ @@ -973,7 +1004,8 @@ pub const JSON_SCHEMA_CLASS_ACCOUNT: &str = r#" "radius_secret", "account_expire", "account_valid_from", - "mail" + "mail", + "oauth2_consent_scope_map" ], "systemmust": [ "displayname", diff --git a/kanidmd/idm/src/constants/uuids.rs b/kanidmd/idm/src/constants/uuids.rs index 41f70fc1d..01d41849d 100644 --- a/kanidmd/idm/src/constants/uuids.rs +++ b/kanidmd/idm/src/constants/uuids.rs @@ -167,6 +167,8 @@ pub const UUID_SCHEMA_ATTR_FERNET_PRIVATE_KEY_STR: Uuid = uuid!("00000000-0000-0000-0000-ffff00000095"); pub const _UUID_SCHEMA_ATTR_CREDENTIAL_UPDATE_INTENT_TOKEN: Uuid = uuid!("00000000-0000-0000-0000-ffff00000096"); +pub const _UUID_SCHEMA_CLASS_OAUTH2_CONSENT_SCOPE_MAP: Uuid = + uuid!("00000000-0000-0000-0000-ffff00000097"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. diff --git a/kanidmd/idm/src/identity.rs b/kanidmd/idm/src/identity.rs index 6f42cbae2..5415ae7e4 100644 --- a/kanidmd/idm/src/identity.rs +++ b/kanidmd/idm/src/identity.rs @@ -6,6 +6,7 @@ use crate::prelude::*; use kanidm_proto::v1::UserAuthToken; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use std::hash::Hash; use std::sync::Arc; @@ -161,4 +162,14 @@ impl Identity { .attribute_equality("memberof", &PartialValue::new_refer(group)), } } + + pub fn get_oauth2_consent_scopes(&self, oauth2_rs: Uuid) -> Option<&BTreeSet> { + match &self.origin { + IdentType::Internal => None, + IdentType::User(u) => u + .entry + .get_ava_as_oauthscopemaps("oauth2_consent_scope_map") + .and_then(|scope_map| scope_map.get(&oauth2_rs)), + } + } } diff --git a/kanidmd/idm/src/idm/delayed.rs b/kanidmd/idm/src/idm/delayed.rs index af053c0e2..84f05e435 100644 --- a/kanidmd/idm/src/idm/delayed.rs +++ b/kanidmd/idm/src/idm/delayed.rs @@ -6,6 +6,7 @@ pub(crate) enum DelayedAction { UnixPwUpgrade(UnixPasswordUpgrade), WebauthnCounterIncrement(WebauthnCounterIncrement), BackupCodeRemoval(BackupCodeRemoval), + Oauth2ConsentGrant(Oauth2ConsentGrant), } pub(crate) struct PasswordUpgrade { @@ -28,3 +29,9 @@ pub(crate) struct BackupCodeRemoval { pub target_uuid: Uuid, pub code_to_remove: String, } + +pub(crate) struct Oauth2ConsentGrant { + pub target_uuid: Uuid, + pub oauth2_rs_uuid: Uuid, + pub scopes: Vec, +} diff --git a/kanidmd/idm/src/idm/oauth2.rs b/kanidmd/idm/src/idm/oauth2.rs index 0966314df..5e657dea1 100644 --- a/kanidmd/idm/src/idm/oauth2.rs +++ b/kanidmd/idm/src/idm/oauth2.rs @@ -6,6 +6,7 @@ //! use crate::identity::IdentityId; +use crate::idm::delayed::{DelayedAction, Oauth2ConsentGrant}; use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerTransaction}; use crate::prelude::*; use crate::value::OAUTHSCOPE_RE; @@ -21,13 +22,14 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::sync::Arc; use time::OffsetDateTime; +use tokio::sync::mpsc::UnboundedSender as Sender; use tracing::trace; use url::{Origin, Url}; use webauthn_rs::base64_data::Base64UrlSafeData; pub use kanidm_proto::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, - AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ConsentRequest, ErrorResponse, + AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, OidcDiscoveryResponse, }; use kanidm_proto::oauth2::{ @@ -149,6 +151,23 @@ struct Oauth2AccessToken { pub auth_time: Option, } +#[derive(Debug)] +pub enum AuthoriseResponse { + ConsentRequested { + // A pretty-name of the client + client_name: String, + // A list of scopes requested / to be issued. + scopes: Vec, + // Extra PII that may be requested + pii_scopes: Vec, + // The users displayname (?) + // pub display_name: String, + // The token we need to be given back to allow this to proceed + consent_token: String, + }, + Permitted(AuthorisePermitSuccess), +} + #[derive(Debug)] pub struct AuthorisePermitSuccess { // Where the RS wants us to go back to. @@ -417,7 +436,7 @@ impl Oauth2ResourceServersReadTransaction { uat: &UserAuthToken, auth_req: &AuthorisationRequest, ct: Duration, - ) -> Result { + ) -> Result { // due to identity processing we already know that: // * the session must be authenticated, and valid // * is within it's valid time window. @@ -500,14 +519,17 @@ impl Oauth2ResourceServersReadTransaction { } // scopes - you need to have every requested scope or this req is denied. - let req_scopes: BTreeSet<_> = auth_req.scope.split_ascii_whitespace().collect(); + let req_scopes: BTreeSet = auth_req + .scope + .split_ascii_whitespace() + .map(str::to_string) + .collect(); if req_scopes.is_empty() { admin_error!("Invalid oauth2 request - must contain at least one requested scope"); return Err(Oauth2Error::InvalidRequest); } - // TODO: Check the scopes by our scope RE rules. - // Oauth2Error::InvalidScope + // Check the scopes by our scope regex validation rules. if !req_scopes.iter().all(|s| OAUTHSCOPE_RE.is_match(s)) { admin_error!( "Invalid oauth2 request - requested scopes failed to pass validation rules" @@ -515,22 +537,22 @@ impl Oauth2ResourceServersReadTransaction { return Err(Oauth2Error::InvalidScope); } - let uat_scopes: BTreeSet<_> = o2rs + let uat_scopes: BTreeSet = o2rs .implicit_scopes .iter() - .map(|s| s.as_str()) .chain( o2rs.scope_maps .iter() .filter_map(|(u, m)| { if ident.is_memberof(*u) { - Some(m.iter().map(|s| s.as_str())) + Some(m.iter()) } else { None } }) .flatten(), ) + .cloned() .collect(); // Needs to use s.to_string due to &&str which can't use the str::to_string @@ -548,45 +570,106 @@ impl Oauth2ResourceServersReadTransaction { return Err(Oauth2Error::AccessDenied); } - // 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. + let consent_previously_granted = + if let Some(consent_scopes) = ident.get_oauth2_consent_scopes(o2rs.uuid) { + req_scopes.eq(consent_scopes) + } else { + false + }; - 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, - redirect_uri: auth_req.redirect_uri.clone(), - scopes: avail_scopes.clone(), - nonce: auth_req.nonce.clone(), - }; + if consent_previously_granted { + admin_info!( + "User has previously consented, permitting. {:?}", + req_scopes + ); - let consent_data = serde_json::to_vec(&consent_req).map_err(|e| { - admin_error!(err = ?e, "Unable to encode consent data"); - Oauth2Error::ServerError(OperationError::SerdeJsonError) - })?; + // Setup for the permit success + let xchg_code = TokenExchangeCode { + uat: uat.clone(), + code_challenge, + redirect_uri: auth_req.redirect_uri.clone(), + scopes: avail_scopes.clone(), + nonce: auth_req.nonce.clone(), + }; - let consent_token = self - .inner - .fernet - .encrypt_at_time(&consent_data, ct.as_secs()); + // 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| { + admin_error!(err = ?e, "Unable to encode xchg_code data"); + Oauth2Error::ServerError(OperationError::SerdeJsonError) + })?; - Ok(ConsentRequest { - client_name: o2rs.displayname.clone(), - scopes: avail_scopes, - consent_token, - }) + let code = o2rs.token_fernet.encrypt_at_time(&code_data, ct.as_secs()); + + Ok(AuthoriseResponse::Permitted(AuthorisePermitSuccess { + redirect_uri: auth_req.redirect_uri.clone(), + state: auth_req.state.clone(), + code, + })) + } else { + // Check that the scopes are the same as a previous consent (if any) + // If oidc, what PII is visible? + // TODO: Scopes map to claims: + // + // * profile - (name, family\_name, given\_name, middle\_name, nickname, preferred\_username, profile, picture, website, gender, birthdate, zoneinfo, locale, and updated\_at) + // * email - (email, email\_verified) + // * address - (address) + // * phone - (phone\_number, phone\_number\_verified) + // + // https://openid.net/specs/openid-connect-basic-1_0.html#StandardClaims + + let pii_scopes = if req_scopes.contains("openid") { + let mut pii_scopes = Vec::with_capacity(2); + if req_scopes.contains("email") { + pii_scopes.push("email".to_string()); + pii_scopes.push("email_verified".to_string()); + } + pii_scopes + } else { + Vec::with_capacity(0) + }; + + // 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. + + 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, + redirect_uri: auth_req.redirect_uri.clone(), + scopes: avail_scopes.clone(), + nonce: auth_req.nonce.clone(), + }; + + let consent_data = serde_json::to_vec(&consent_req).map_err(|e| { + admin_error!(err = ?e, "Unable to encode consent data"); + Oauth2Error::ServerError(OperationError::SerdeJsonError) + })?; + + let consent_token = self + .inner + .fernet + .encrypt_at_time(&consent_data, ct.as_secs()); + + Ok(AuthoriseResponse::ConsentRequested { + client_name: o2rs.displayname.clone(), + scopes: avail_scopes, + pii_scopes, + consent_token, + }) + } } - pub fn check_oauth2_authorise_permit( + pub(crate) fn check_oauth2_authorise_permit( &self, ident: &Identity, uat: &UserAuthToken, consent_token: &str, ct: Duration, + async_tx: &Sender, ) -> Result { // Decode the consent req with our system fernet key. Use a ttl of 5 minutes. let consent_req: ConsentToken = self @@ -627,12 +710,11 @@ impl Oauth2ResourceServersReadTransaction { })?; // 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(), - scopes: consent_req.scopes, + scopes: consent_req.scopes.clone(), nonce: consent_req.nonce, }; @@ -644,6 +726,17 @@ impl Oauth2ResourceServersReadTransaction { let code = o2rs.token_fernet.encrypt_at_time(&code_data, ct.as_secs()); + // Everything is DONE! Now submit that it's all happy and the user consented correctly. + // this will let them bypass consent steps in the future. + // Submit that we consented to the delayed action queue + if let Err(_) = async_tx.send(DelayedAction::Oauth2ConsentGrant(Oauth2ConsentGrant { + target_uuid: uat.uuid, + oauth2_rs_uuid: o2rs.uuid, + scopes: consent_req.scopes, + })) { + admin_warn!("unable to queue delayed oauth2 consent grant, continuing ... "); + } + Ok(AuthorisePermitSuccess { redirect_uri: consent_req.redirect_uri, state: consent_req.state, @@ -706,6 +799,9 @@ impl Oauth2ResourceServersReadTransaction { token_req: &AccessTokenRequest, ct: Duration, ) -> Result { + // TODO: add refresh token grant type. + // If it's a refresh token grant, are the consent permissions the same? + if token_req.grant_type != "authorization_code" { admin_warn!("Invalid oauth2 grant_type (should be 'authorization_code')"); return Err(Oauth2Error::InvalidRequest); @@ -907,11 +1003,13 @@ impl Oauth2ResourceServersReadTransaction { .token_fernet .encrypt_at_time(&access_token_data, ct.as_secs()); + let refresh_token = None; + Ok(AccessTokenResponse { access_token, token_type: "bearer".to_string(), expires_in, - refresh_token: None, + refresh_token, scope, id_token, }) @@ -1237,11 +1335,12 @@ fn parse_basic_authz(client_authz: &str) -> Result<(String, String), Oauth2Error #[cfg(test)] mod tests { use crate::event::CreateEvent; - use crate::idm::oauth2::Oauth2Error; + use crate::idm::delayed::DelayedAction; + use crate::idm::oauth2::{AuthoriseResponse, Oauth2Error}; use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::prelude::*; - use crate::event::ModifyEvent; + use crate::event::{DeleteEvent, ModifyEvent}; use kanidm_proto::oauth2::*; use kanidm_proto::v1::{AuthType, UserAuthToken}; @@ -1304,7 +1403,7 @@ mod tests { ct: Duration, enable_pkce: bool, enable_legacy_crypto: bool, - ) -> (String, UserAuthToken, Identity) { + ) -> (String, UserAuthToken, Identity, Uuid) { let mut idms_prox_write = idms.proxy_write(ct); let uuid = Uuid::new_v4(); @@ -1365,7 +1464,7 @@ mod tests { idms_prox_write.commit().expect("failed to commit"); - (secret, uat, ident) + (secret, uat, ident, uuid) } fn setup_idm_admin( @@ -1392,49 +1491,65 @@ mod tests { #[test] fn test_idm_oauth2_basic_function() { - 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, true, false); + 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, true, false); - let idms_prox_read = idms.proxy_read(); + let idms_prox_read = idms.proxy_read(); - // Get an ident/uat for now. + // Get an ident/uat for now. - // == Setup the authorisation request - let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + // == 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); + 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"); + // Should be in the consent phase; + let consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); + }; - // Check we are reflecting the CSRF properly. - assert!(permit_success.state == "123"); + // == 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_token, ct) + .expect("Failed to perform oauth2 permit"); - // == Submit the token exchange code. + // Assert that the consent was submitted + match idms_delayed.async_rx.blocking_recv() { + Some(DelayedAction::Oauth2ConsentGrant(_)) => {} + _ => assert!(false), + } - 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: Some("test_resource_server".to_string()), - client_secret: Some(secret), - // From the first step. - code_verifier, - }; + // Check we are reflecting the CSRF properly. + assert!(permit_success.state == "123"); - let token_response = idms_prox_read - .check_oauth2_token_exchange(None, &token_req, ct) - .expect("Failed to perform oauth2 token exchange"); + // == Submit the token exchange code. - // 🎉 We got a token! In the future we can then check introspection from this point. - assert!(token_response.token_type == "bearer"); - }) + 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: Some("test_resource_server".to_string()), + client_secret: Some(secret), + // From the first step. + code_verifier, + }; + + let token_response = idms_prox_read + .check_oauth2_token_exchange(None, &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] @@ -1444,7 +1559,7 @@ mod tests { _idms_delayed: &mut IdmServerDelayed| { // Test invalid oauth2 authorisation states/requests. let ct = Duration::from_secs(TEST_CURRENT_TIME); - let (_secret, uat, ident) = setup_oauth2_resource_server(idms, ct, true, false); + let (_secret, uat, ident, _) = setup_oauth2_resource_server(idms, ct, true, false); let (anon_uat, anon_ident) = setup_idm_admin(idms, ct, AuthType::Anonymous); let (idm_admin_uat, idm_admin_ident) = setup_idm_admin(idms, ct, AuthType::PasswordMfa); @@ -1608,7 +1723,7 @@ mod tests { _idms_delayed: &mut IdmServerDelayed| { // Test invalid oauth2 authorisation states/requests. let ct = Duration::from_secs(TEST_CURRENT_TIME); - let (_secret, uat, ident) = setup_oauth2_resource_server(idms, ct, true, false); + let (_secret, uat, ident, _) = setup_oauth2_resource_server(idms, ct, true, false); let (uat2, ident2) = { let mut idms_prox_write = idms.proxy_write(ct); @@ -1632,6 +1747,15 @@ mod tests { let consent_request = good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + let consent_token = if let AuthoriseResponse::ConsentRequested { + consent_token, .. + } = consent_request + { + consent_token + } else { + unreachable!(); + }; + // Invalid permits // * expired token, aka past ttl. assert!( @@ -1639,7 +1763,7 @@ mod tests { .check_oauth2_authorise_permit( &ident, &uat, - &consent_request.consent_token, + &consent_token, ct + Duration::from_secs(TOKEN_EXPIRE), ) .unwrap_err() @@ -1652,12 +1776,7 @@ mod tests { assert!( idms_prox_read - .check_oauth2_authorise_permit( - &ident2, - &uat, - &consent_request.consent_token, - ct, - ) + .check_oauth2_authorise_permit(&ident2, &uat, &consent_token, ct,) .unwrap_err() == OperationError::InvalidSessionState ); @@ -1665,12 +1784,7 @@ mod tests { // * incorrect session id assert!( idms_prox_read - .check_oauth2_authorise_permit( - &ident, - &uat2, - &consent_request.consent_token, - ct, - ) + .check_oauth2_authorise_permit(&ident, &uat2, &consent_token, ct,) .unwrap_err() == OperationError::InvalidSessionState ); @@ -1679,240 +1793,276 @@ mod tests { #[test] fn test_idm_oauth2_invalid_token_exchange_requests() { - run_idm_test!(|_qs: &QueryServer, - idms: &IdmServer, - _idms_delayed: &mut IdmServerDelayed| { - let ct = Duration::from_secs(TEST_CURRENT_TIME); - let (secret, mut uat, ident) = setup_oauth2_resource_server(idms, ct, true, false); + run_idm_test!( + |_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, mut uat, ident, _) = + setup_oauth2_resource_server(idms, ct, true, false); - // ⚠️ 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); + // ⚠️ 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(); + 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); + // == 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 consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); + }; - // == Submit the token exchange code. + // == 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_token, ct) + .expect("Failed to perform oauth2 permit"); - // 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, - client_secret: None, - // From the first step. - code_verifier: code_verifier.clone(), - }; + // Assert that the consent was submitted + match idms_delayed.async_rx.blocking_recv() { + Some(DelayedAction::Oauth2ConsentGrant(_)) => {} + _ => assert!(false), + } - assert!( - idms_prox_read - .check_oauth2_token_exchange(Some("not base64"), &token_req, ct) - .unwrap_err() - == Oauth2Error::AuthenticationRequired - ); + // == Submit the token exchange code. - // * doesn't have : - let client_authz = Some(base64::encode(format!("test_resource_server {}", secret))); - assert!( - idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .unwrap_err() - == Oauth2Error::AuthenticationRequired - ); + // 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, + client_secret: None, + // From the first step. + code_verifier: code_verifier.clone(), + }; - // * invalid client_id - let client_authz = Some(base64::encode(format!("NOT A REAL SERVER:{}", secret))); - assert!( - idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .unwrap_err() - == Oauth2Error::AuthenticationRequired - ); + assert!( + idms_prox_read + .check_oauth2_token_exchange(Some("not base64"), &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); - // * valid client_id, but invalid secret - let client_authz = Some(base64::encode("test_resource_server:12345")); - assert!( - idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .unwrap_err() - == Oauth2Error::AuthenticationRequired - ); + // * doesn't have : + let client_authz = Some(base64::encode(format!("test_resource_server {}", secret))); + assert!( + idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); - // ✅ Now the valid client_authz is in place. - let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret))); - // * expired exchange code (took too long) - assert!( - idms_prox_read - .check_oauth2_token_exchange( - client_authz.as_deref(), - &token_req, - ct + Duration::from_secs(TOKEN_EXPIRE) - ) - .unwrap_err() - == Oauth2Error::InvalidRequest - ); + // * invalid client_id + let client_authz = Some(base64::encode(format!("NOT A REAL SERVER:{}", secret))); + assert!( + idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); - // * 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( - client_authz.as_deref(), - &token_req, - ct + Duration::from_secs(UAT_EXPIRE) - ) - .unwrap_err() - == Oauth2Error::AccessDenied - ); + // * valid client_id, but invalid secret + let client_authz = Some(base64::encode("test_resource_server:12345")); + assert!( + idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) + .unwrap_err() + == Oauth2Error::AuthenticationRequired + ); - // * 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, - client_secret: None, - code_verifier: code_verifier.clone(), - }; - assert!( - idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .unwrap_err() - == Oauth2Error::InvalidRequest - ); + // ✅ Now the valid client_authz is in place. + let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret))); + // * expired exchange code (took too long) + assert!( + idms_prox_read + .check_oauth2_token_exchange( + client_authz.as_deref(), + &token_req, + ct + Duration::from_secs(TOKEN_EXPIRE) + ) + .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, - client_secret: None, - code_verifier, - }; - assert!( - idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .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( + client_authz.as_deref(), + &token_req, + ct + Duration::from_secs(UAT_EXPIRE) + ) + .unwrap_err() + == Oauth2Error::AccessDenied + ); - // * 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, - client_secret: None, - code_verifier: Some("12345".to_string()), - }; - assert!( - idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .unwrap_err() - == Oauth2Error::InvalidRequest - ); - }) + // * 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, + client_secret: None, + code_verifier: code_verifier.clone(), + }; + assert!( + idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &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, + client_secret: None, + code_verifier, + }; + assert!( + idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &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, + client_secret: None, + code_verifier: Some("12345".to_string()), + }; + assert!( + idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) + .unwrap_err() + == Oauth2Error::InvalidRequest + ); + } + ) } #[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, true, false); - let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret))); + 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, true, false); + let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret))); - let idms_prox_read = idms.proxy_read(); + 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); + // == 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 consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); + }; - 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, - client_secret: None, - code_verifier, - }; - let oauth2_token = idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .expect("Unable to exchange for oauth2 token"); + // == 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_token, ct) + .expect("Failed to perform oauth2 permit"); - // 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.as_deref().unwrap(), &intr_request, ct) - .expect("Failed to inspect token"); + // Assert that the consent was submitted + match idms_delayed.async_rx.blocking_recv() { + Some(DelayedAction::Oauth2ConsentGrant(_)) => {} + _ => assert!(false), + } - eprintln!("👉 {:?}", intr_response); - assert!(intr_response.active); - assert!(intr_response.scope.as_deref() == Some("openid")); - 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)); + 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, + client_secret: None, + code_verifier, + }; + let oauth2_token = idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) + .expect("Unable to exchange for oauth2 token"); - drop(idms_prox_read); - // start a write, + // 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.as_deref().unwrap(), + &intr_request, + ct, + ) + .expect("Failed to inspect token"); - 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()); + eprintln!("👉 {:?}", intr_response); + assert!(intr_response.active); + assert!(intr_response.scope.as_deref() == Some("openid")); + 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)); - // 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.unwrap(), &intr_request, ct) - .expect("Failed to inspect token"); + drop(idms_prox_read); + // start a write, - assert!(!intr_response.active); - }) + 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.unwrap(), &intr_request, ct) + .expect("Failed to inspect token"); + + assert!(!intr_response.active); + } + ) } #[test] @@ -1921,7 +2071,7 @@ mod tests { idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed| { let ct = Duration::from_secs(TEST_CURRENT_TIME); - let (_secret, uat, ident) = setup_oauth2_resource_server(idms, ct, true, false); + let (_secret, uat, ident, _) = setup_oauth2_resource_server(idms, ct, true, false); let (uat2, ident2) = { let mut idms_prox_write = idms.proxy_write(ct); @@ -1946,8 +2096,17 @@ mod tests { let consent_request = good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + let consent_token = if let AuthoriseResponse::ConsentRequested { + consent_token, .. + } = consent_request + { + consent_token + } else { + unreachable!(); + }; + let reject_success = idms_prox_read - .check_oauth2_authorise_reject(&ident, &uat, &consent_request.consent_token, ct) + .check_oauth2_authorise_reject(&ident, &uat, &consent_token, ct) .expect("Failed to perform oauth2 reject"); assert!(reject_success == redirect_uri); @@ -1956,12 +2115,7 @@ mod tests { let past_ct = Duration::from_secs(TEST_CURRENT_TIME + 301); assert!( idms_prox_read - .check_oauth2_authorise_reject( - &ident, - &uat, - &consent_request.consent_token, - past_ct - ) + .check_oauth2_authorise_reject(&ident, &uat, &consent_token, past_ct) .unwrap_err() == OperationError::CryptographyError ); @@ -1977,24 +2131,14 @@ mod tests { // Wrong UAT assert!( idms_prox_read - .check_oauth2_authorise_reject( - &ident, - &uat2, - &consent_request.consent_token, - ct - ) + .check_oauth2_authorise_reject(&ident, &uat2, &consent_token, ct) .unwrap_err() == OperationError::InvalidSessionState ); // Wrong ident assert!( idms_prox_read - .check_oauth2_authorise_reject( - &ident2, - &uat, - &consent_request.consent_token, - ct - ) + .check_oauth2_authorise_reject(&ident2, &uat, &consent_token, ct) .unwrap_err() == OperationError::InvalidSessionState ); @@ -2007,7 +2151,7 @@ mod tests { idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed| { let ct = Duration::from_secs(TEST_CURRENT_TIME); - let (_secret, _uat, _ident) = setup_oauth2_resource_server(idms, ct, true, false); + let (_secret, _uat, _ident, _) = setup_oauth2_resource_server(idms, ct, true, false); let idms_prox_read = idms.proxy_read(); @@ -2139,108 +2283,123 @@ mod tests { #[test] fn test_idm_oauth2_openid_extensions() { - 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, true, false); - let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret))); + 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, true, false); + let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret))); - let idms_prox_read = idms.proxy_read(); + let idms_prox_read = idms.proxy_read(); - let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); - let consent_request = - good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + 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 consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); + }; - // == Submit the token exchange code. - 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, - client_secret: None, - // From the first step. - code_verifier, - }; + // == 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_token, ct) + .expect("Failed to perform oauth2 permit"); - let token_response = idms_prox_read - .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) - .expect("Failed to perform oauth2 token exchange"); + // Assert that the consent was submitted + match idms_delayed.async_rx.blocking_recv() { + Some(DelayedAction::Oauth2ConsentGrant(_)) => {} + _ => assert!(false), + } - // 🎉 We got a token! - assert!(token_response.token_type == "bearer"); + // == Submit the token exchange code. + 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, + client_secret: None, + // From the first step. + code_verifier, + }; - let id_token = token_response.id_token.expect("No id_token in response!"); - let access_token = token_response.access_token; + let token_response = idms_prox_read + .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) + .expect("Failed to perform oauth2 token exchange"); - let mut jwkset = idms_prox_read - .oauth2_openid_publickey("test_resource_server") - .expect("Failed to get public key"); - let public_jwk = jwkset.keys.pop().expect("no such jwk"); + // 🎉 We got a token! + assert!(token_response.token_type == "bearer"); - let jws_validator = - JwsValidator::try_from(&public_jwk).expect("failed to build validator"); + let id_token = token_response.id_token.expect("No id_token in response!"); + let access_token = token_response.access_token; - let oidc_unverified = - OidcUnverified::from_str(&id_token).expect("Failed to parse id_token"); + let mut jwkset = idms_prox_read + .oauth2_openid_publickey("test_resource_server") + .expect("Failed to get public key"); + let public_jwk = jwkset.keys.pop().expect("no such jwk"); - let iat = ct.as_secs() as i64; + let jws_validator = + JwsValidator::try_from(&public_jwk).expect("failed to build validator"); - let oidc = oidc_unverified - .validate(&jws_validator, iat) - .expect("Failed to verify oidc"); + let oidc_unverified = + OidcUnverified::from_str(&id_token).expect("Failed to parse id_token"); - // Are the id_token values what we expect? - assert!( - oidc.iss - == Url::parse("https://idm.example.com/oauth2/openid/test_resource_server") - .unwrap() - ); - assert!(oidc.sub == OidcSubject::U(*UUID_ADMIN)); - assert!(oidc.aud == "test_resource_server"); - assert!(oidc.iat == iat); - assert!(oidc.nbf == Some(iat)); - assert!(oidc.exp == iat + (AUTH_SESSION_EXPIRY as i64)); - assert!(oidc.auth_time.is_none()); - // Is nonce correctly passed through? - assert!(oidc.nonce == Some("abcdef".to_string())); - assert!(oidc.at_hash.is_none()); - assert!(oidc.acr.is_none()); - assert!(oidc.amr == Some(vec!["passwordmfa".to_string()])); - assert!(oidc.azp == Some("test_resource_server".to_string())); - assert!(oidc.jti.is_none()); - assert!(oidc.s_claims.name == Some("System Administrator".to_string())); - assert!(oidc.s_claims.preferred_username == Some("admin@example.com".to_string())); - assert!(oidc.s_claims.scopes == vec!["openid".to_string()]); - assert!(oidc.claims.is_empty()); - // Does our access token work with the userinfo endpoint? - // Do the id_token details line up to the userinfo? - let userinfo = idms_prox_read - .oauth2_openid_userinfo("test_resource_server", &access_token, ct) - .expect("failed to get userinfo"); + let iat = ct.as_secs() as i64; - assert!(oidc.iss == userinfo.iss); - assert!(oidc.sub == userinfo.sub); - assert!(oidc.aud == userinfo.aud); - assert!(oidc.iat == userinfo.iat); - assert!(oidc.nbf == userinfo.nbf); - assert!(oidc.exp == userinfo.exp); - assert!(userinfo.auth_time.is_none()); - assert!(userinfo.nonce.is_none()); - assert!(userinfo.at_hash.is_none()); - assert!(userinfo.acr.is_none()); - assert!(oidc.amr == userinfo.amr); - assert!(oidc.azp == userinfo.azp); - assert!(userinfo.jti.is_none()); - assert!(oidc.s_claims == userinfo.s_claims); - assert!(userinfo.claims.is_empty()); - }) + let oidc = oidc_unverified + .validate(&jws_validator, iat) + .expect("Failed to verify oidc"); + + // Are the id_token values what we expect? + assert!( + oidc.iss + == Url::parse("https://idm.example.com/oauth2/openid/test_resource_server") + .unwrap() + ); + assert!(oidc.sub == OidcSubject::U(*UUID_ADMIN)); + assert!(oidc.aud == "test_resource_server"); + assert!(oidc.iat == iat); + assert!(oidc.nbf == Some(iat)); + assert!(oidc.exp == iat + (AUTH_SESSION_EXPIRY as i64)); + assert!(oidc.auth_time.is_none()); + // Is nonce correctly passed through? + assert!(oidc.nonce == Some("abcdef".to_string())); + assert!(oidc.at_hash.is_none()); + assert!(oidc.acr.is_none()); + assert!(oidc.amr == Some(vec!["passwordmfa".to_string()])); + assert!(oidc.azp == Some("test_resource_server".to_string())); + assert!(oidc.jti.is_none()); + assert!(oidc.s_claims.name == Some("System Administrator".to_string())); + assert!(oidc.s_claims.preferred_username == Some("admin@example.com".to_string())); + assert!(oidc.s_claims.scopes == vec!["openid".to_string()]); + assert!(oidc.claims.is_empty()); + // Does our access token work with the userinfo endpoint? + // Do the id_token details line up to the userinfo? + let userinfo = idms_prox_read + .oauth2_openid_userinfo("test_resource_server", &access_token, ct) + .expect("failed to get userinfo"); + + assert!(oidc.iss == userinfo.iss); + assert!(oidc.sub == userinfo.sub); + assert!(oidc.aud == userinfo.aud); + assert!(oidc.iat == userinfo.iat); + assert!(oidc.nbf == userinfo.nbf); + assert!(oidc.exp == userinfo.exp); + assert!(userinfo.auth_time.is_none()); + assert!(userinfo.nonce.is_none()); + assert!(userinfo.at_hash.is_none()); + assert!(userinfo.acr.is_none()); + assert!(oidc.amr == userinfo.amr); + assert!(oidc.azp == userinfo.azp); + assert!(userinfo.jti.is_none()); + assert!(oidc.s_claims == userinfo.s_claims); + assert!(userinfo.claims.is_empty()); + } + ) } // Check insecure pkce behaviour. @@ -2250,7 +2409,7 @@ mod tests { idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed| { let ct = Duration::from_secs(TEST_CURRENT_TIME); - let (_secret, uat, ident) = setup_oauth2_resource_server(idms, ct, false, false); + let (_secret, uat, ident, _) = setup_oauth2_resource_server(idms, ct, false, false); let idms_prox_read = idms.proxy_read(); @@ -2282,83 +2441,300 @@ mod tests { #[test] fn test_idm_oauth2_openid_legacy_crypto() { - 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, false, true); - let idms_prox_read = idms.proxy_read(); - // The public key url should offer an rs key - // discovery should offer RS256 - let discovery = idms_prox_read - .oauth2_openid_discovery("test_resource_server") - .expect("Failed to get discovery"); + 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, false, true); + let idms_prox_read = idms.proxy_read(); + // The public key url should offer an rs key + // discovery should offer RS256 + let discovery = idms_prox_read + .oauth2_openid_discovery("test_resource_server") + .expect("Failed to get discovery"); - let mut jwkset = idms_prox_read - .oauth2_openid_publickey("test_resource_server") - .expect("Failed to get public key"); + let mut jwkset = idms_prox_read + .oauth2_openid_publickey("test_resource_server") + .expect("Failed to get public key"); - let jwk = jwkset.keys.pop().expect("no such jwk"); - let public_jwk = jwk.clone(); + let jwk = jwkset.keys.pop().expect("no such jwk"); + let public_jwk = jwk.clone(); - match jwk { - Jwk::RSA { alg, use_, kid, .. } => { - match ( - alg.unwrap(), - &discovery.id_token_signing_alg_values_supported[0], - ) { - (JwaAlg::RS256, IdTokenSignAlg::RS256) => {} - _ => panic!(), + match jwk { + Jwk::RSA { alg, use_, kid, .. } => { + match ( + alg.unwrap(), + &discovery.id_token_signing_alg_values_supported[0], + ) { + (JwaAlg::RS256, IdTokenSignAlg::RS256) => {} + _ => panic!(), + }; + assert!(use_.unwrap() == JwkUse::Sig); + assert!(kid.unwrap() == "test_resource_server") + } + _ => panic!(), + }; + + // Check that the id_token is signed with the correct key. + let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + + let consent_request = + good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + + let consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); }; - assert!(use_.unwrap() == JwkUse::Sig); - assert!(kid.unwrap() == "test_resource_server") + + // == 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_token, ct) + .expect("Failed to perform oauth2 permit"); + + // Assert that the consent was submitted + match idms_delayed.async_rx.blocking_recv() { + Some(DelayedAction::Oauth2ConsentGrant(_)) => {} + _ => assert!(false), } - _ => panic!(), - }; - // Check that the id_token is signed with the correct key. - let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + // == Submit the token exchange code. + 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: Some("test_resource_server".to_string()), + client_secret: Some(secret), + // From the first step. + code_verifier, + }; - let consent_request = - good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + let token_response = idms_prox_read + .check_oauth2_token_exchange(None, &token_req, ct) + .expect("Failed to perform oauth2 token exchange"); - // == 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"); + // 🎉 We got a token! + assert!(token_response.token_type == "bearer"); + let id_token = token_response.id_token.expect("No id_token in response!"); - // == Submit the token exchange code. - 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: Some("test_resource_server".to_string()), - client_secret: Some(secret), - // From the first step. - code_verifier, - }; + let jws_validator = + JwsValidator::try_from(&public_jwk).expect("failed to build validator"); - let token_response = idms_prox_read - .check_oauth2_token_exchange(None, &token_req, ct) - .expect("Failed to perform oauth2 token exchange"); + let oidc_unverified = + OidcUnverified::from_str(&id_token).expect("Failed to parse id_token"); - // 🎉 We got a token! - assert!(token_response.token_type == "bearer"); - let id_token = token_response.id_token.expect("No id_token in response!"); + let iat = ct.as_secs() as i64; - let jws_validator = - JwsValidator::try_from(&public_jwk).expect("failed to build validator"); + let oidc = oidc_unverified + .validate(&jws_validator, iat) + .expect("Failed to verify oidc"); - let oidc_unverified = - OidcUnverified::from_str(&id_token).expect("Failed to parse id_token"); + assert!(oidc.sub == OidcSubject::U(*UUID_ADMIN)); + } + ) + } - let iat = ct.as_secs() as i64; + #[test] + fn test_idm_oauth2_consent_granted_and_changed_workflow() { + 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, true, false); - let oidc = oidc_unverified - .validate(&jws_validator, iat) - .expect("Failed to verify oidc"); + let idms_prox_read = idms.proxy_read(); - assert!(oidc.sub == OidcSubject::U(*UUID_ADMIN)); - }) + let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + let consent_request = + good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + + // Should be in the consent phase; + let consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); + }; + + // == 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_token, ct) + .expect("Failed to perform oauth2 permit"); + + drop(idms_prox_read); + + // Assert that the consent was submitted + let o2cg = match idms_delayed.async_rx.blocking_recv() { + Some(DelayedAction::Oauth2ConsentGrant(o2cg)) => o2cg, + _ => unreachable!(), + }; + + // Manually submit the consent. + let mut idms_prox_write = idms.proxy_write(ct); + assert!(idms_prox_write.process_oauth2consentgrant(&o2cg).is_ok()); + assert!(idms_prox_write.commit().is_ok()); + + // == Now try the authorise again, should be in the permitted state. + let idms_prox_read = idms.proxy_read(); + + // We need to reload our identity + let ident = idms_prox_read + .process_uat_to_identity(&uat, ct) + .expect("Unable to process uat"); + + let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + let consent_request = + good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + + // Should be in the consent phase; + let _permit_success = + if let AuthoriseResponse::Permitted(permit_success) = consent_request { + permit_success + } else { + unreachable!(); + }; + + drop(idms_prox_read); + + // Great! Now change the scopes on the oauth2 instance, this revokes the permit. + let idms_prox_write = idms.proxy_write(ct); + + let me_extend_scopes = unsafe { + ModifyEvent::new_internal_invalid( + filter!(f_eq( + "oauth2_rs_name", + PartialValue::new_iname("test_resource_server") + )), + ModifyList::new_list(vec![Modify::Present( + AttrString::from("oauth2_rs_implicit_scopes"), + Value::new_oauthscope("email").expect("invalid oauthscope"), + )]), + ) + }; + + assert!(idms_prox_write.qs_write.modify(&me_extend_scopes).is_ok()); + assert!(idms_prox_write.commit().is_ok()); + + // And do the workflow once more to see if we need to consent again. + + let idms_prox_read = idms.proxy_read(); + + // We need to reload our identity + let ident = idms_prox_read + .process_uat_to_identity(&uat, ct) + .expect("Unable to process uat"); + + let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + + let auth_req = AuthorisationRequest { + response_type: "code".to_string(), + client_id: "test_resource_server".to_string(), + state: "123".to_string(), + pkce_request: Some(PkceRequest { + code_challenge: Base64UrlSafeData(code_challenge), + code_challenge_method: CodeChallengeMethod::S256, + }), + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + scope: "openid email".to_string(), + nonce: Some("abcdef".to_string()), + oidc_ext: Default::default(), + unknown_keys: Default::default(), + }; + + let consent_request = idms_prox_read + .check_oauth2_authorisation(&ident, &uat, &auth_req, ct) + .expect("Oauth2 authorisation failed"); + + // Should be in the consent phase; + let _consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); + }; + + // Success! We had to consent again due to the change :) + } + ) + } + + #[test] + fn test_idm_oauth2_consent_granted_refint_cleanup_on_delete() { + run_idm_test!( + |_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (_secret, uat, ident, o2rs_uuid) = + setup_oauth2_resource_server(idms, ct, true, false); + + // Assert there are no consent maps yet. + assert!(ident.get_oauth2_consent_scopes(o2rs_uuid).is_none()); + + let idms_prox_read = idms.proxy_read(); + + let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + let consent_request = + good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge); + + // Should be in the consent phase; + let consent_token = + if let AuthoriseResponse::ConsentRequested { consent_token, .. } = + consent_request + { + consent_token + } else { + unreachable!(); + }; + + // == 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_token, ct) + .expect("Failed to perform oauth2 permit"); + + drop(idms_prox_read); + + // Assert that the consent was submitted + let o2cg = match idms_delayed.async_rx.blocking_recv() { + Some(DelayedAction::Oauth2ConsentGrant(o2cg)) => o2cg, + _ => unreachable!(), + }; + + // Manually submit the consent. + let mut idms_prox_write = idms.proxy_write(ct); + assert!(idms_prox_write.process_oauth2consentgrant(&o2cg).is_ok()); + + let ident = idms_prox_write + .process_uat_to_identity(&uat, ct) + .expect("Unable to process uat"); + + // Assert that the ident now has the consents. + assert!( + ident.get_oauth2_consent_scopes(o2rs_uuid) + == Some(&btreeset!["openid".to_string()]) + ); + + // Now trigger the delete of the RS + let de = unsafe { + DeleteEvent::new_internal_invalid(filter!(f_eq( + "oauth2_rs_name", + PartialValue::new_iname("test_resource_server") + ))) + }; + + trace!("ATTACHHERE"); + assert!(idms_prox_write.qs_write.delete(&de).is_ok()); + // Assert the consent maps are gone. + let ident = idms_prox_write + .process_uat_to_identity(&uat, ct) + .expect("Unable to process uat"); + assert!(ident.get_oauth2_consent_scopes(o2rs_uuid).is_none()); + + assert!(idms_prox_write.commit().is_ok()); + } + ) } } diff --git a/kanidmd/idm/src/idm/server.rs b/kanidmd/idm/src/idm/server.rs index d64944b52..8cc107ee6 100644 --- a/kanidmd/idm/src/idm/server.rs +++ b/kanidmd/idm/src/idm/server.rs @@ -17,8 +17,8 @@ use crate::idm::event::{ use crate::idm::mfareg::{MfaRegCred, MfaRegNext, MfaRegSession}; use crate::idm::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, - AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, ConsentRequest, JwkKeySet, - Oauth2Error, Oauth2ResourceServers, Oauth2ResourceServersReadTransaction, + AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, + JwkKeySet, Oauth2Error, Oauth2ResourceServers, Oauth2ResourceServersReadTransaction, Oauth2ResourceServersWriteTransaction, OidcDiscoveryResponse, OidcToken, }; use crate::idm::radius::RadiusAccount; @@ -34,7 +34,8 @@ use crate::utils::{ use crate::actors::v1_write::QueryServerWriteV1; use crate::idm::delayed::{ - DelayedAction, PasswordUpgrade, UnixPasswordUpgrade, WebauthnCounterIncrement, + DelayedAction, Oauth2ConsentGrant, PasswordUpgrade, UnixPasswordUpgrade, + WebauthnCounterIncrement, }; use hashbrown::HashSet; @@ -138,6 +139,7 @@ pub struct IdmServerProxyReadTransaction<'a> { pub qs_read: QueryServerReadTransaction<'a>, uat_jwt_validator: CowCellReadTxn, oauth2rs: Oauth2ResourceServersReadTransaction, + async_tx: Sender, } pub struct IdmServerProxyWriteTransaction<'a> { @@ -158,7 +160,7 @@ pub struct IdmServerProxyWriteTransaction<'a> { } pub struct IdmServerDelayed { - async_rx: Receiver, + pub(crate) async_rx: Receiver, } impl IdmServer { @@ -307,6 +309,7 @@ impl IdmServer { qs_read: self.qs.read_async().await, uat_jwt_validator: self.uat_jwt_validator.read(), oauth2rs: self.oauth2rs.read(), + async_tx: self.async_tx.clone(), } } @@ -1177,7 +1180,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { uat: &UserAuthToken, auth_req: &AuthorisationRequest, ct: Duration, - ) -> Result { + ) -> Result { self.oauth2rs .check_oauth2_authorisation(ident, uat, auth_req, ct) } @@ -1190,7 +1193,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { ct: Duration, ) -> Result { self.oauth2rs - .check_oauth2_authorise_permit(ident, uat, consent_req, ct) + .check_oauth2_authorise_permit(ident, uat, consent_req, ct, &self.async_tx) } pub fn check_oauth2_authorise_reject( @@ -2044,6 +2047,27 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { ) } + pub(crate) fn process_oauth2consentgrant( + &mut self, + o2cg: &Oauth2ConsentGrant, + ) -> Result<(), OperationError> { + let modlist = ModifyList::new_list(vec![ + Modify::Removed( + AttrString::from("oauth2_consent_scope_map"), + PartialValue::OauthScopeMap(o2cg.oauth2_rs_uuid), + ), + Modify::Present( + AttrString::from("oauth2_consent_scope_map"), + Value::OauthScopeMap(o2cg.oauth2_rs_uuid, o2cg.scopes.iter().cloned().collect()), + ), + ]); + + self.qs_write.internal_modify( + &filter_all!(f_eq("uuid", PartialValue::new_uuid(o2cg.target_uuid))), + &modlist, + ) + } + pub(crate) fn process_delayedaction( &mut self, da: DelayedAction, @@ -2053,6 +2077,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { DelayedAction::UnixPwUpgrade(upwu) => self.process_unixpwupgrade(&upwu), DelayedAction::WebauthnCounterIncrement(wci) => self.process_webauthncounterinc(&wci), DelayedAction::BackupCodeRemoval(bcr) => self.process_backupcoderemoval(&bcr), + DelayedAction::Oauth2ConsentGrant(o2cg) => self.process_oauth2consentgrant(&o2cg), } } diff --git a/kanidmd/idm/src/server.rs b/kanidmd/idm/src/server.rs index 907585c3a..2c6dc6e55 100644 --- a/kanidmd/idm/src/server.rs +++ b/kanidmd/idm/src/server.rs @@ -2318,6 +2318,7 @@ impl<'a> QueryServerWriteTransaction<'a> { JSON_SCHEMA_ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, JSON_SCHEMA_ATTR_RS256_PRIVATE_KEY_DER, JSON_SCHEMA_ATTR_CREDENTIAL_UPDATE_INTENT_TOKEN, + JSON_SCHEMA_ATTR_OAUTH2_CONSENT_SCOPE_MAP, JSON_SCHEMA_CLASS_PERSON, JSON_SCHEMA_CLASS_ORGPERSON, JSON_SCHEMA_CLASS_GROUP, diff --git a/kanidmd/score/src/https/oauth2.rs b/kanidmd/score/src/https/oauth2.rs index 32e8035ca..2fbc0a7b6 100644 --- a/kanidmd/score/src/https/oauth2.rs +++ b/kanidmd/score/src/https/oauth2.rs @@ -2,9 +2,10 @@ use super::v1::{json_rest_event_get, json_rest_event_post}; use super::{to_tide_response, AppState, RequestExtensions}; use kanidm::idm::oauth2::{ AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, - ErrorResponse, Oauth2Error, + AuthoriseResponse, ErrorResponse, Oauth2Error, }; use kanidm::prelude::*; +use kanidm_proto::oauth2::AuthorisationResponse; use kanidm_proto::v1::Entry as ProtoEntry; use serde::{Deserialize, Serialize}; @@ -185,7 +186,13 @@ pub async fn oauth2_id_delete(req: tide::Request) -> tide::Result { pub async fn oauth2_authorise_post(mut req: tide::Request) -> tide::Result { let auth_req: AuthorisationRequest = req.body_json().await?; - oauth2_authorise(req, auth_req).await + oauth2_authorise(req, auth_req).await.map(|mut res| { + if res.status() == 302 { + // in post, we need the redirect not to be issued, so we mask 302 to 200 + res.set_status(200); + } + res + }) } pub async fn oauth2_authorise_get(req: tide::Request) -> tide::Result { @@ -219,12 +226,43 @@ async fn oauth2_authorise( .await; match res { - Ok(consent_req) => { + Ok(AuthoriseResponse::ConsentRequested { + client_name, + scopes, + pii_scopes, + consent_token, + }) => { // 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| { + tide::Body::from_json(&AuthorisationResponse::ConsentRequested { + client_name, + scopes, + pii_scopes, + consent_token, + }) + .map(|b| { + res.set_body(b); + res + }) + } + Ok(AuthoriseResponse::Permitted(AuthorisePermitSuccess { + mut redirect_uri, + state, + code, + })) => { + let mut res = tide::Response::new(302); + + redirect_uri + .query_pairs_mut() + .clear() + .append_pair("state", &state) + .append_pair("code", &code); + res.insert_header("Location", redirect_uri.as_str()); + // I think the client server needs this + res.insert_header("Access-Control-Allow-Origin", redirect_uri.origin().ascii_serialization()); + tide::Body::from_json(&AuthorisationResponse::Permitted).map(|b| { res.set_body(b); res }) @@ -265,8 +303,10 @@ pub async fn oauth2_authorise_permit_post(mut req: tide::Request) -> t oauth2_authorise_permit(req, consent_req) .await .map(|mut res| { - // in post, we need the redirect not to be issued, so we mask 302 to 200 - res.set_status(200); + if res.status() == 302 { + // in post, we need the redirect not to be issued, so we mask 302 to 200 + res.set_status(200); + } res }) } @@ -318,7 +358,7 @@ async fn oauth2_authorise_permit( .append_pair("code", &code); res.insert_header("Location", redirect_uri.as_str()); // I think the client server needs this - // res.insert_header("Access-Control-Allow-Origin", redirect_uri.origin().ascii_serialization()); + res.insert_header("Access-Control-Allow-Origin", redirect_uri.origin().ascii_serialization()); res } Err(_e) => { diff --git a/kanidmd/score/tests/oauth2_test.rs b/kanidmd/score/tests/oauth2_test.rs index 1de9e8ed0..887664d03 100644 --- a/kanidmd/score/tests/oauth2_test.rs +++ b/kanidmd/score/tests/oauth2_test.rs @@ -5,7 +5,7 @@ use crate::common::{setup_async_test, ADMIN_TEST_PASSWORD}; use compact_jwt::{JwkKeySet, JwsValidator, OidcToken, OidcUnverified}; use kanidm_proto::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, - AccessTokenResponse, ConsentRequest, OidcDiscoveryResponse, + AccessTokenResponse, AuthorisationResponse, OidcDiscoveryResponse, }; use oauth2_ext::PkceCodeChallenge; use std::collections::HashMap; @@ -217,18 +217,25 @@ async fn test_oauth2_openid_basic_flow() { assert!(response.status() == reqwest::StatusCode::OK); assert_no_cache!(response); - let consent_req: ConsentRequest = response + let consent_req: AuthorisationResponse = response .json() .await .expect("Failed to access response body"); + let consent_token = + if let AuthorisationResponse::ConsentRequested { consent_token, .. } = consent_req { + consent_token + } else { + unreachable!(); + }; + // 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())]) + .query(&[("token", consent_token.as_str())]) .send() .await .expect("Failed to send request."); diff --git a/kanidmd_web_ui/pkg/README.md b/kanidmd_web_ui/pkg/README.md index 091463545..116d44b17 100644 --- a/kanidmd_web_ui/pkg/README.md +++ b/kanidmd_web_ui/pkg/README.md @@ -24,7 +24,7 @@ If you want to deploy Kanidm to see what it can do, you should read the kanidm b - [Kanidm book (Latest stable)](https://kanidm.github.io/kanidm/stable/) -We also publish limited [support guidelines](https://github.com/kanidm/kanidm/blob/master/project_docs/RELEASE_AND_SUPPORT.md)). +We also publish limited [support guidelines](https://github.com/kanidm/kanidm/blob/master/project_docs/RELEASE_AND_SUPPORT.md). ## Code of Conduct / Ethics diff --git a/kanidmd_web_ui/pkg/favicon.svg b/kanidmd_web_ui/pkg/favicon.svg deleted file mode 100644 index 7f235eca1..000000000 --- a/kanidmd_web_ui/pkg/favicon.svg +++ /dev/null @@ -1,3 +0,0 @@ - -🦀 - \ No newline at end of file diff --git a/kanidmd_web_ui/pkg/kanidmd_web_ui.d.ts b/kanidmd_web_ui/pkg/kanidmd_web_ui.d.ts index b6b13351b..64d194aa2 100644 --- a/kanidmd_web_ui/pkg/kanidmd_web_ui.d.ts +++ b/kanidmd_web_ui/pkg/kanidmd_web_ui.d.ts @@ -12,9 +12,9 @@ export interface InitOutput { readonly __wbindgen_malloc: (a: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number) => number; readonly __wbindgen_export_2: WebAssembly.Table; - readonly _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__ha6256632cf9b6e15: (a: number, b: number, c: number) => void; - readonly _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h026107843485189d: (a: number, b: number, c: number) => void; - readonly _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hf1579e791c670fad: (a: number, b: number, c: number) => void; + readonly _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h517d7fce3d158796: (a: number, b: number, c: number) => void; + readonly _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h6c41bce435f08bdb: (a: number, b: number, c: number) => void; + readonly _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hea19f293916f5b3a: (a: number, b: number, c: number) => void; readonly __wbindgen_add_to_stack_pointer: (a: number) => number; readonly __wbindgen_free: (a: number, b: number) => void; readonly __wbindgen_exn_store: (a: number) => void; diff --git a/kanidmd_web_ui/pkg/kanidmd_web_ui.js b/kanidmd_web_ui/pkg/kanidmd_web_ui.js index d896a6eef..724460f69 100644 --- a/kanidmd_web_ui/pkg/kanidmd_web_ui.js +++ b/kanidmd_web_ui/pkg/kanidmd_web_ui.js @@ -241,7 +241,7 @@ function logError(f, args) { function __wbg_adapter_30(arg0, arg1, arg2) { _assertNum(arg0); _assertNum(arg1); - wasm._dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__ha6256632cf9b6e15(arg0, arg1, addHeapObject(arg2)); + wasm._dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h517d7fce3d158796(arg0, arg1, addHeapObject(arg2)); } function makeMutClosure(arg0, arg1, dtor, f) { @@ -271,7 +271,7 @@ function makeMutClosure(arg0, arg1, dtor, f) { function __wbg_adapter_33(arg0, arg1, arg2) { _assertNum(arg0); _assertNum(arg1); - wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h026107843485189d(arg0, arg1, addHeapObject(arg2)); + wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h6c41bce435f08bdb(arg0, arg1, addHeapObject(arg2)); } let stack_pointer = 32; @@ -285,7 +285,7 @@ function __wbg_adapter_36(arg0, arg1, arg2) { try { _assertNum(arg0); _assertNum(arg1); - wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hf1579e791c670fad(arg0, arg1, addBorrowedObject(arg2)); + wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hea19f293916f5b3a(arg0, arg1, addBorrowedObject(arg2)); } finally { heap[stack_pointer++] = undefined; } @@ -425,6 +425,9 @@ async function init(input) { const ret = arg0; return addHeapObject(ret); }; + imports.wbg.__wbindgen_object_drop_ref = function(arg0) { + takeObject(arg0); + }; imports.wbg.__wbg_error_09919627ac0992f5 = function() { return logError(function (arg0, arg1) { try { console.error(getStringFromWasm0(arg0, arg1)); @@ -443,9 +446,6 @@ async function init(input) { getInt32Memory0()[arg0 / 4 + 1] = len0; getInt32Memory0()[arg0 / 4 + 0] = ptr0; }, arguments) }; - imports.wbg.__wbindgen_object_drop_ref = function(arg0) { - takeObject(arg0); - }; imports.wbg.__wbindgen_cb_drop = function(arg0) { const obj = takeObject(arg0).original; if (obj.cnt-- == 1) { @@ -869,16 +869,16 @@ async function init(input) { const ret = wasm.memory; return addHeapObject(ret); }; - imports.wbg.__wbindgen_closure_wrapper19774 = function() { return logError(function (arg0, arg1, arg2) { - const ret = makeClosure(arg0, arg1, 1253, __wbg_adapter_30); + imports.wbg.__wbindgen_closure_wrapper19806 = function() { return logError(function (arg0, arg1, arg2) { + const ret = makeClosure(arg0, arg1, 1261, __wbg_adapter_30); return addHeapObject(ret); }, arguments) }; - imports.wbg.__wbindgen_closure_wrapper21955 = function() { return logError(function (arg0, arg1, arg2) { - const ret = makeMutClosure(arg0, arg1, 1273, __wbg_adapter_33); + imports.wbg.__wbindgen_closure_wrapper21982 = function() { return logError(function (arg0, arg1, arg2) { + const ret = makeMutClosure(arg0, arg1, 1281, __wbg_adapter_33); return addHeapObject(ret); }, arguments) }; - imports.wbg.__wbindgen_closure_wrapper22397 = function() { return logError(function (arg0, arg1, arg2) { - const ret = makeMutClosure(arg0, arg1, 1300, __wbg_adapter_36); + imports.wbg.__wbindgen_closure_wrapper22336 = function() { return logError(function (arg0, arg1, arg2) { + const ret = makeMutClosure(arg0, arg1, 1307, __wbg_adapter_36); return addHeapObject(ret); }, arguments) }; diff --git a/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm b/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm index 416c4809d..095af8a64 100644 Binary files a/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm and b/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm differ diff --git a/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm.d.ts b/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm.d.ts index 1218d65ab..66bca6863 100644 --- a/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm.d.ts +++ b/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm.d.ts @@ -5,9 +5,9 @@ export function run_app(a: number): void; export function __wbindgen_malloc(a: number): number; export function __wbindgen_realloc(a: number, b: number, c: number): number; export const __wbindgen_export_2: WebAssembly.Table; -export function _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__ha6256632cf9b6e15(a: number, b: number, c: number): void; -export function _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h026107843485189d(a: number, b: number, c: number): void; -export function _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hf1579e791c670fad(a: number, b: number, c: number): void; +export function _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h517d7fce3d158796(a: number, b: number, c: number): void; +export function _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h6c41bce435f08bdb(a: number, b: number, c: number): void; +export function _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hea19f293916f5b3a(a: number, b: number, c: number): void; export function __wbindgen_add_to_stack_pointer(a: number): number; export function __wbindgen_free(a: number, b: number): void; export function __wbindgen_exn_store(a: number): void; diff --git a/kanidmd_web_ui/src/oauth2.rs b/kanidmd_web_ui/src/oauth2.rs index bab7c0afb..aae6bc300 100644 --- a/kanidmd_web_ui/src/oauth2.rs +++ b/kanidmd_web_ui/src/oauth2.rs @@ -14,8 +14,8 @@ use crate::models; use crate::utils; pub use kanidm_proto::oauth2::{ - AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, - ConsentRequest, ErrorResponse, + AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, AuthorisationResponse, + CodeChallengeMethod, ErrorResponse, }; enum State { @@ -25,7 +25,13 @@ enum State { TokenCheck(String), // Token check done, lets do it. SubmitAuthReq(String), - Consent(String, ConsentRequest), + Consent { + token: String, + client_name: String, + scopes: Vec, + pii_scopes: Vec, + consent_token: String, + }, ConsentGranted, ErrInvalidRequest, } @@ -38,9 +44,17 @@ pub enum Oauth2Msg { LoginProceed, ConsentGranted, TokenValid, - Consent(ConsentRequest), + Consent { + client_name: String, + scopes: Vec, + pii_scopes: Vec, + consent_token: String, + }, Redirect(String), - Error { emsg: String, kopid: Option }, + Error { + emsg: String, + kopid: Option, + }, } impl From for Oauth2Msg { @@ -116,13 +130,41 @@ impl Oauth2App { let resp: Response = resp_value.dyn_into().expect_throw("Invalid response type"); let status = resp.status(); let headers = resp.headers(); + let kopid = headers.get("x-kanidm-opid").ok().flatten(); if status == 200 { let jsval = JsFuture::from(resp.json()?).await?; - let state: ConsentRequest = jsval.into_serde().expect_throw("Invalid response type"); - Ok(Oauth2Msg::Consent(state)) + let state: AuthorisationResponse = jsval + .into_serde() + .map_err(|e| { + let e_msg = format!("serde error -> {:?}", e); + console::log!(e_msg.as_str()); + }) + .expect_throw("Invalid response type"); + match state { + AuthorisationResponse::ConsentRequested { + client_name, + scopes, + pii_scopes, + consent_token, + } => Ok(Oauth2Msg::Consent { + client_name, + scopes, + pii_scopes, + consent_token, + }), + AuthorisationResponse::Permitted => { + if let Some(loc) = headers.get("location").ok().flatten() { + Ok(Oauth2Msg::Redirect(loc)) + } else { + Ok(Oauth2Msg::Error { + emsg: "no location header".to_string(), + kopid, + }) + } + } + } } else { - let kopid = headers.get("x-kanidm-opid").ok().flatten(); let text = JsFuture::from(resp.text()?).await?; let emsg = text.as_string().unwrap_or_else(|| "".to_string()); Ok(Oauth2Msg::Error { emsg, kopid }) @@ -131,9 +173,9 @@ impl Oauth2App { async fn fetch_consent_token( token: String, - consent_req: ConsentRequest, + consent_token: String, ) -> Result { - let consentreq_jsvalue = serde_json::to_string(&consent_req.consent_token) + let consentreq_jsvalue = serde_json::to_string(&consent_token) .map(|s| JsValue::from(&s)) .expect_throw("Failed to serialise consent_req"); @@ -294,9 +336,20 @@ impl Component for Oauth2App { }; true } - Oauth2Msg::Consent(consent_req) => { + Oauth2Msg::Consent { + client_name, + scopes, + pii_scopes, + consent_token, + } => { self.state = match &self.state { - State::SubmitAuthReq(token) => State::Consent(token.clone(), consent_req), + State::SubmitAuthReq(token) => State::Consent { + token: token.clone(), + client_name, + scopes, + pii_scopes, + consent_token, + }, _ => { console::log!("Invalid state transition"); State::ErrInvalidRequest @@ -306,9 +359,13 @@ impl Component for Oauth2App { } Oauth2Msg::ConsentGranted => { self.state = match &self.state { - State::Consent(token, consent_req) => { + State::Consent { + token, + consent_token, + .. + } => { let token_c = token.clone(); - let cr_c = (*consent_req).clone(); + let cr_c = consent_token.clone(); ctx.link().send_future(async { match Self::fetch_consent_token(token_c, cr_c).await { Ok(v) => v, @@ -380,8 +437,36 @@ impl Component for Oauth2App { } } - State::Consent(_, query) => { - let client_name = query.client_name.clone(); + State::Consent { + token: _, + client_name, + scopes: _, + pii_scopes, + consent_token: _, + } => { + let client_name = client_name.clone(); + + let pii_req = if pii_scopes.is_empty() { + html! { +
+

{ "This site will not have access to your personal information" }

+

{ "If this site requests personal information in the future we will check with you" }

+
+ } + } else { + html! { +
+

{ "This site has requested to see the following personal information" }

+
    + { + pii_scopes.iter().map(|s| html! {
  • { s }
  • } ).collect::() + } +
+

{ "If this site requests different personal information in the future we will check with you again" }

+
+ } + }; + // html! {
@@ -393,7 +478,9 @@ impl Component for Oauth2App { } ) } action="javascript:void(0);" > -

{"Consent to Proceed to " }{ client_name }

+

{"Consent to Proceed to " }{ client_name }

+ { pii_req } +