diff --git a/book/src/developers/designs/domain_join.md b/book/src/developers/designs/domain_join.md index 76eb6f1ab..0f625c39b 100644 --- a/book/src/developers/designs/domain_join.md +++ b/book/src/developers/designs/domain_join.md @@ -16,18 +16,18 @@ retrieve ssh public keys, and then perform sudo authentication. This has the obvious caveat that anyone can stand up a machine that trusts a Kanidm instance. This presents a double edged sword: -- By configuring a machine to authenticate via Kanidm, there is full trust - in the authentication decisions Kanidm makes. -- Users of Kanidm may be tricked into accessing a machine that is not managed - by their IT or other central authority. +- By configuring a machine to authenticate via Kanidm, there is full trust in the authentication + decisions Kanidm makes. +- Users of Kanidm may be tricked into accessing a machine that is not managed by their IT or other + central authority. -To prevent this, UNIX authentication should be configurable to prevent usage from unregistered machines. -This will require the machine to present machine authentication credentials simultaneously with the -user's credentials. +To prevent this, UNIX authentication should be configurable to prevent usage from unregistered +machines. This will require the machine to present machine authentication credentials simultaneously +with the user's credentials. A potential change is removing the current unix password auth mechanism as a whole. Instead the -user's auth token would contain a TPM bound credential that only the domain joined machine's TPM could -access and use. +user's auth token would contain a TPM bound credential that only the domain joined machine's TPM +could access and use. ### Requesting Cryptographic Credentials diff --git a/book/src/integrations/oauth2.md b/book/src/integrations/oauth2.md index d804d2f1d..4aa78aff6 100644 --- a/book/src/integrations/oauth2.md +++ b/book/src/integrations/oauth2.md @@ -200,6 +200,78 @@ kanidm system oauth2 reset-secrets Each resource server has unique signing keys and access secrets, so this is limited to each resource server. +## Custom Claim Maps + +Some OIDC clients may consume custom claims from an id token for access control or other policy +decisions. Each custom claim is a key:values set, where there can be many values associated to a +claim name. Different applications may expect these values to be formatted (joined) in different +ways. + +Claim values are mapped based on membership to groups. When an account is a member of multiple +groups that would recieve the same claim, the values of these maps are merged. + +To create or update a claim map on a client: + +``` +kanidm system oauth2 update-claim-map [values]... +kanidm system oauth2 update-claim-map nextcloud account_role nextcloud_admins admin login ... +``` + +To change the join strategy for a claim name. Valid strategies are csv (comma separated value), ssv +(space separated value) and array (a native json array). The default strategy is array. + +``` +kanidm system oauth2 update-claim-map-join [csv|ssv|array] +kanidm system oauth2 update-claim-map-join nextcloud account_role csv +``` + +``` +# Example claim formats +# csv +claim: "value_a,value_b" + +# ssv +claim: "value_a value_b" + +# array +claim: ["value_a", "value_b"] +``` + +To delete a group from a claim map + +``` +kanidm system oauth2 delete-claim-map +kanidm system oauth2 delete-claim-map nextcloud account_role nextcloud_admins +``` + +## Public Client Configuration + +Some applications are unable to provide client authentication. A common example is single page web +applications that act as the OAuth2 client and its corresponding webserver that is the resource +server. In this case the SPA is unable to act as a confidential client since the basic secret would +need to be embedded in every client. + +Another common example is native applications that use a redirect to localhost. These can't have a +client secret embedded, so must act as public clients. + +Public clients for this reason require PKCE to bind a specific browser session to its OAuth2 +exchange. PKCE can not be disabled for public clients for this reason. + +To create an OAuth2 public resource server: + +```bash +kanidm system oauth2 create-public +kanidm system oauth2 create-public mywebapp "My Web App" https://webapp.example.com +``` + +To allow localhost redirection + +```bash +kanidm system oauth2 enable-localhost-redirects +kanidm system oauth2 disable-localhost-redirects +kanidm system oauth2 enable-localhost-redirects mywebapp +``` + ## Extended Options for Legacy Clients Not all resource servers support modern standards like PKCE or ECDSA. In these situations it may be @@ -228,33 +300,6 @@ To enable legacy cryptograhy (RSA PKCS1-5 SHA256): kanidm system oauth2 warning-enable-legacy-crypto ``` -## Public Client Configuration - -Some applications are unable to provide client authentication. A common example is single page web -applications that act as the OAuth2 client and its corresponding webserver that is the resource -server. In this case the SPA is unable to act as a confidential client since the basic secret would -need to be embedded in every client. - -Public clients for this reason require PKCE to bind a specific browser session to its OAuth2 -exchange. PKCE can not be disabled for public clients for this reason. - - - -{{#template ../templates/kani-warning.md -imagepath=../images -title=WARNING -text=Public clients have many limitations compared to confidential clients. You should avoid them if possible. -}} - - - -To create an OAuth2 public resource server: - -```bash -kanidm system oauth2 create-public -kanidm system oauth2 create-public mywebapp "My Web App" https://webapp.example.com -``` - ## Example Integrations ### Apache mod\_auth\_openidc diff --git a/libs/client/src/oauth.rs b/libs/client/src/oauth.rs index 6d686def5..2238518b9 100644 --- a/libs/client/src/oauth.rs +++ b/libs/client/src/oauth.rs @@ -1,9 +1,10 @@ use crate::{ClientError, KanidmClient}; use kanidm_proto::constants::{ ATTR_DISPLAYNAME, ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, - ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, ATTR_OAUTH2_RS_NAME, ATTR_OAUTH2_RS_ORIGIN, + ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT, ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, + ATTR_OAUTH2_PREFER_SHORT_USERNAME, ATTR_OAUTH2_RS_NAME, ATTR_OAUTH2_RS_ORIGIN, }; -use kanidm_proto::internal::ImageValue; +use kanidm_proto::internal::{ImageValue, Oauth2ClaimMapJoin}; use kanidm_proto::v1::Entry; use reqwest::multipart; use std::collections::BTreeMap; @@ -302,7 +303,7 @@ impl KanidmClient { attrs: BTreeMap::new(), }; update_oauth2_rs.attrs.insert( - "oauth2_prefer_short_username".to_string(), + ATTR_OAUTH2_PREFER_SHORT_USERNAME.to_string(), vec!["true".to_string()], ); self.perform_patch_request(format!("/v1/oauth2/{}", id).as_str(), update_oauth2_rs) @@ -314,10 +315,80 @@ impl KanidmClient { attrs: BTreeMap::new(), }; update_oauth2_rs.attrs.insert( - "oauth2_prefer_short_username".to_string(), + ATTR_OAUTH2_PREFER_SHORT_USERNAME.to_string(), vec!["false".to_string()], ); self.perform_patch_request(format!("/v1/oauth2/{}", id).as_str(), update_oauth2_rs) .await } + + pub async fn idm_oauth2_rs_enable_public_localhost_redirect( + &self, + id: &str, + ) -> Result<(), ClientError> { + let mut update_oauth2_rs = Entry { + attrs: BTreeMap::new(), + }; + update_oauth2_rs.attrs.insert( + ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT.to_string(), + vec!["true".to_string()], + ); + self.perform_patch_request(format!("/v1/oauth2/{}", id).as_str(), update_oauth2_rs) + .await + } + + pub async fn idm_oauth2_rs_disable_public_localhost_redirect( + &self, + id: &str, + ) -> Result<(), ClientError> { + let mut update_oauth2_rs = Entry { + attrs: BTreeMap::new(), + }; + update_oauth2_rs.attrs.insert( + ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT.to_string(), + vec!["false".to_string()], + ); + self.perform_patch_request(format!("/v1/oauth2/{}", id).as_str(), update_oauth2_rs) + .await + } + + pub async fn idm_oauth2_rs_update_claim_map( + &self, + id: &str, + claim_name: &str, + group_id: &str, + values: &[String], + ) -> Result<(), ClientError> { + let values: Vec = values.to_vec(); + self.perform_post_request( + format!("/v1/oauth2/{}/_claimmap/{}/{}", id, claim_name, group_id).as_str(), + values, + ) + .await + } + + pub async fn idm_oauth2_rs_update_claim_map_join( + &self, + id: &str, + claim_name: &str, + join: Oauth2ClaimMapJoin, + ) -> Result<(), ClientError> { + self.perform_post_request( + format!("/v1/oauth2/{}/_claimmap/{}", id, claim_name).as_str(), + join, + ) + .await + } + + pub async fn idm_oauth2_rs_delete_claim_map( + &self, + id: &str, + claim_name: &str, + group_id: &str, + ) -> Result<(), ClientError> { + self.perform_delete_request( + format!("/v1/oauth2/{}/_claimmap/{}/{}", id, claim_name, group_id).as_str(), + ) + .await + } } diff --git a/proto/src/constants.rs b/proto/src/constants.rs index b86216afd..6ebc1079d 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -120,10 +120,12 @@ pub const ATTR_NSUNIQUEID: &str = "nsuniqueid"; pub const ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE: &str = "oauth2_allow_insecure_client_disable_pkce"; +pub const ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT: &str = "oauth2_allow_localhost_redirect"; pub const ATTR_OAUTH2_CONSENT_SCOPE_MAP: &str = "oauth2_consent_scope_map"; pub const ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE: &str = "oauth2_jwt_legacy_crypto_enable"; pub const ATTR_OAUTH2_PREFER_SHORT_USERNAME: &str = "oauth2_prefer_short_username"; pub const ATTR_OAUTH2_RS_BASIC_SECRET: &str = "oauth2_rs_basic_secret"; +pub const ATTR_OAUTH2_RS_CLAIM_MAP: &str = "oauth2_rs_claim_map"; pub const ATTR_OAUTH2_RS_IMPLICIT_SCOPES: &str = "oauth2_rs_implicit_scopes"; pub const ATTR_OAUTH2_RS_NAME: &str = "oauth2_rs_name"; pub const ATTR_OAUTH2_RS_ORIGIN_LANDING: &str = "oauth2_rs_origin_landing"; diff --git a/proto/src/internal.rs b/proto/src/internal.rs index 891f1377c..d027dd18c 100644 --- a/proto/src/internal.rs +++ b/proto/src/internal.rs @@ -151,25 +151,32 @@ impl FsType { } } -impl From for FsType { - fn from(s: String) -> Self { - s.as_str().into() - } -} +impl TryFrom<&str> for FsType { + type Error = (); -impl From<&str> for FsType { - fn from(s: &str) -> Self { + fn try_from(s: &str) -> Result { match s { - "zfs" => FsType::Zfs, - _ => FsType::Generic, + "zfs" => Ok(FsType::Zfs), + "generic" => Ok(FsType::Generic), + _ => Err(()), } } } +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +pub enum Oauth2ClaimMapJoin { + #[serde(rename = "csv")] + Csv, + #[serde(rename = "ssv")] + Ssv, + #[serde(rename = "array")] + Array, +} + #[test] fn test_fstype_deser() { - assert_eq!(FsType::from("zfs"), FsType::Zfs); - assert_eq!(FsType::from("generic"), FsType::Generic); - assert_eq!(FsType::from(" "), FsType::Generic); - assert_eq!(FsType::from("crab๐Ÿฆ€"), FsType::Generic); + assert_eq!(FsType::try_from("zfs"), Ok(FsType::Zfs)); + assert_eq!(FsType::try_from("generic"), Ok(FsType::Generic)); + assert_eq!(FsType::try_from(" "), Err(())); + assert_eq!(FsType::try_from("crab๐Ÿฆ€"), Err(())); } diff --git a/server/core/src/actors/v1_write.rs b/server/core/src/actors/v1_write.rs index b300fb81b..bd27d6219 100644 --- a/server/core/src/actors/v1_write.rs +++ b/server/core/src/actors/v1_write.rs @@ -1,6 +1,7 @@ use std::{iter, sync::Arc}; use kanidm_proto::internal::ImageValue; +use kanidm_proto::internal::Oauth2ClaimMapJoin as ProtoOauth2ClaimMapJoin; use kanidm_proto::v1::{ AccountUnixExtend, CUIntentToken, CUSessionToken, CUStatus, CreateRequest, DeleteRequest, Entry as ProtoEntry, GroupUnixExtend, Modify as ProtoModify, ModifyList as ProtoModifyList, @@ -30,7 +31,7 @@ use kanidmd_lib::{ idm::server::{IdmServer, IdmServerTransaction}, idm::serviceaccount::{DestroyApiTokenEvent, GenerateApiTokenEvent}, modify::{Modify, ModifyInvalid, ModifyList}, - value::{PartialValue, Value}, + value::{OauthClaimMapJoin, PartialValue, Value}, }; use kanidmd_lib::prelude::*; @@ -1362,6 +1363,183 @@ impl QueryServerWriteV1 { .and_then(|_| idms_prox_write.commit().map(|_| ())) } + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn handle_oauth2_claimmap_update( + &self, + client_auth_info: ClientAuthInfo, + claim_name: String, + group: String, + claims: Vec, + filter: Filter, + eventid: Uuid, + ) -> Result<(), OperationError> { + // Because this is from internal, we can generate a real modlist, rather + // than relying on the proto ones. + let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await; + + let ident = idms_prox_write + .validate_client_auth_info_to_ident(client_auth_info, ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity"); + e + })?; + + let group_uuid = idms_prox_write + .qs_write + .name_to_uuid(group.as_str()) + .map_err(|e| { + admin_error!(err = ?e, "Error resolving group name to target"); + e + })?; + + let ml = ModifyList::new_append( + Attribute::OAuth2RsClaimMap, + Value::new_oauthclaimmap(claim_name, group_uuid, claims.into_iter().collect()) + .ok_or_else(|| { + OperationError::InvalidAttribute("Invalid Oauth Claim Map syntax".to_string()) + })?, + ); + + let mdf = match ModifyEvent::from_internal_parts( + ident, + &ml, + &filter, + &idms_prox_write.qs_write, + ) { + Ok(m) => m, + Err(e) => { + admin_error!(err = ?e, "Failed to begin modify"); + return Err(e); + } + }; + + trace!(?mdf, "Begin modify event"); + + idms_prox_write + .qs_write + .modify(&mdf) + .and_then(|_| idms_prox_write.commit().map(|_| ())) + } + + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn handle_oauth2_claimmap_join_update( + &self, + client_auth_info: ClientAuthInfo, + claim_name: String, + join: ProtoOauth2ClaimMapJoin, + filter: Filter, + eventid: Uuid, + ) -> Result<(), OperationError> { + // Because this is from internal, we can generate a real modlist, rather + // than relying on the proto ones. + let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await; + + let ident = idms_prox_write + .validate_client_auth_info_to_ident(client_auth_info, ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity"); + e + })?; + + let join = match join { + ProtoOauth2ClaimMapJoin::Csv => OauthClaimMapJoin::CommaSeparatedValue, + ProtoOauth2ClaimMapJoin::Ssv => OauthClaimMapJoin::SpaceSeparatedValue, + ProtoOauth2ClaimMapJoin::Array => OauthClaimMapJoin::JsonArray, + }; + + let ml = ModifyList::new_append( + Attribute::OAuth2RsClaimMap, + Value::OauthClaimMap(claim_name, join), + ); + + let mdf = match ModifyEvent::from_internal_parts( + ident, + &ml, + &filter, + &idms_prox_write.qs_write, + ) { + Ok(m) => m, + Err(e) => { + admin_error!(err = ?e, "Failed to begin modify"); + return Err(e); + } + }; + + trace!(?mdf, "Begin modify event"); + + idms_prox_write + .qs_write + .modify(&mdf) + .and_then(|_| idms_prox_write.commit().map(|_| ())) + } + + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn handle_oauth2_claimmap_delete( + &self, + client_auth_info: ClientAuthInfo, + claim_name: String, + group: String, + filter: Filter, + eventid: Uuid, + ) -> Result<(), OperationError> { + let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await; + + let ident = idms_prox_write + .validate_client_auth_info_to_ident(client_auth_info, ct) + .map_err(|e| { + admin_error!(err = ?e, "Invalid identity"); + e + })?; + + let group_uuid = idms_prox_write + .qs_write + .name_to_uuid(group.as_str()) + .map_err(|e| { + admin_error!(err = ?e, "Error resolving group name to target"); + e + })?; + + let ml = ModifyList::new_remove( + Attribute::OAuth2RsClaimMap, + PartialValue::OauthClaim(claim_name, group_uuid), + ); + + let mdf = match ModifyEvent::from_internal_parts( + ident, + &ml, + &filter, + &idms_prox_write.qs_write, + ) { + Ok(m) => m, + Err(e) => { + admin_error!(err = ?e, "Failed to begin modify"); + return Err(e); + } + }; + + trace!(?mdf, "Begin modify event"); + + idms_prox_write + .qs_write + .modify(&mdf) + .and_then(|_| idms_prox_write.commit().map(|_| ())) + } + #[instrument( level = "info", skip_all, diff --git a/server/core/src/https/v1.rs b/server/core/src/https/v1.rs index 9520f1a7b..c37da7bdc 100644 --- a/server/core/src/https/v1.rs +++ b/server/core/src/https/v1.rs @@ -2856,6 +2856,15 @@ pub(crate) fn route_setup(state: ServerState) -> Router { post(super::v1_oauth2::oauth2_id_sup_scopemap_post) .delete(super::v1_oauth2::oauth2_id_sup_scopemap_delete), ) + .route( + "/v1/oauth2/:rs_name/_claimmap/:claim_name/:group", + post(super::v1_oauth2::oauth2_id_claimmap_post) + .delete(super::v1_oauth2::oauth2_id_claimmap_delete), + ) + .route( + "/v1/oauth2/:rs_name/_claimmap/:claim_name", + post(super::v1_oauth2::oauth2_id_claimmap_join_post), + ) .route("/v1/raw/create", post(raw_create)) .route("/v1/raw/modify", post(raw_modify)) .route("/v1/raw/delete", post(raw_delete)) diff --git a/server/core/src/https/v1_oauth2.rs b/server/core/src/https/v1_oauth2.rs index 423b83c80..3d69f8b8b 100644 --- a/server/core/src/https/v1_oauth2.rs +++ b/server/core/src/https/v1_oauth2.rs @@ -8,7 +8,7 @@ use super::ServerState; use crate::https::extractors::VerifiedClientInformation; use axum::extract::{Path, State}; use axum::{Extension, Json}; -use kanidm_proto::internal::{ImageType, ImageValue}; +use kanidm_proto::internal::{ImageType, ImageValue, Oauth2ClaimMapJoin}; use kanidm_proto::v1::Entry as ProtoEntry; use kanidmd_lib::prelude::*; use kanidmd_lib::valueset::image::ImageValueThings; @@ -221,6 +221,98 @@ pub(crate) async fn oauth2_id_scopemap_delete( .map_err(WebError::from) } +#[utoipa::path( + patch, + path = "/v1/oauth2/{rs_name}/_claimmap/{claim_name}/{group}", + request_body=Vec, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Modify the claim map for a given OAuth2 Resource Server +pub(crate) async fn oauth2_id_claimmap_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + Path((rs_name, claim_name, group)): Path<(String, String, String)>, + Json(claims): Json>, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_claimmap_update( + client_auth_info, + claim_name, + group, + claims, + filter, + kopid.eventid, + ) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + patch, + path = "/v1/oauth2/{rs_name}/_claimmap/{claim_name}", + request_body=Oauth2ClaimMapJoin, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Modify the claim map join strategy for a given OAuth2 Resource Server +pub(crate) async fn oauth2_id_claimmap_join_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + Path((rs_name, claim_name)): Path<(String, String)>, + Json(join): Json, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_claimmap_join_update( + client_auth_info, + claim_name, + join, + filter, + kopid.eventid, + ) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + delete, + path = "/v1/oauth2/{rs_name}/_claimmap/{claim_name}/{group}", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +// Delete a claim map for a given OAuth2 Resource Server +pub(crate) async fn oauth2_id_claimmap_delete( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + Path((rs_name, claim_name, group)): Path<(String, String, String)>, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_claimmap_delete(client_auth_info, claim_name, group, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + #[utoipa::path( post, path = "/v1/oauth2/{rs_name}/_sup_scopemap/{group}", diff --git a/server/lib/src/be/dbvalue.rs b/server/lib/src/be/dbvalue.rs index a73174d6b..f919e4c3e 100644 --- a/server/lib/src/be/dbvalue.rs +++ b/server/lib/src/be/dbvalue.rs @@ -5,6 +5,7 @@ use hashbrown::HashSet; use kanidm_proto::internal::ImageType; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; +use std::collections::{BTreeMap, BTreeSet}; use url::Url; use uuid::Uuid; use webauthn_rs::prelude::{ @@ -398,6 +399,28 @@ pub struct DbValueAddressV1 { pub country: String, } +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] +pub enum DbValueOauthClaimMapJoinV1 { + #[serde(rename = "c")] + CommaSeparatedValue, + #[serde(rename = "s")] + SpaceSeparatedValue, + #[serde(rename = "a")] + JsonArray, +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum DbValueOauthClaimMap { + V1 { + #[serde(rename = "n")] + name: String, + #[serde(rename = "j")] + join: DbValueOauthClaimMapJoinV1, + #[serde(rename = "d")] + values: BTreeMap>, + }, +} + #[derive(Serialize, Deserialize, Debug)] pub struct DbValueOauthScopeMapV1 { #[serde(rename = "u")] @@ -672,6 +695,8 @@ pub enum DbValueSetV2 { OauthScope(Vec), #[serde(rename = "OM")] OauthScopeMap(Vec), + #[serde(rename = "OC")] + OauthClaimMap(Vec), #[serde(rename = "E2")] PrivateBinary(Vec>), #[serde(rename = "PB")] @@ -736,6 +761,7 @@ impl DbValueSetV2 { DbValueSetV2::PhoneNumber(_primary, set) => set.len(), DbValueSetV2::Address(set) => set.len(), DbValueSetV2::Url(set) => set.len(), + DbValueSetV2::OauthClaimMap(set) => set.len(), DbValueSetV2::OauthScope(set) => set.len(), DbValueSetV2::OauthScopeMap(set) => set.len(), DbValueSetV2::PrivateBinary(set) => set.len(), diff --git a/server/lib/src/be/idl_sqlite.rs b/server/lib/src/be/idl_sqlite.rs index a91a3bc13..f552e07ec 100644 --- a/server/lib/src/be/idl_sqlite.rs +++ b/server/lib/src/be/idl_sqlite.rs @@ -1886,7 +1886,7 @@ impl IdlSqlite { OperationError::BackendEngine })?; // Get not pop here - let conn = guard.get(0).ok_or_else(|| { + let conn = guard.front().ok_or_else(|| { error!("Unable to retrieve connection from pool"); OperationError::BackendEngine })?; diff --git a/server/lib/src/constants/acp.rs b/server/lib/src/constants/acp.rs index 71c3926c1..cf7bf6f59 100644 --- a/server/lib/src/constants/acp.rs +++ b/server/lib/src/constants/acp.rs @@ -622,6 +622,104 @@ lazy_static! { }; } +lazy_static! { + pub static ref IDM_ACP_OAUTH2_MANAGE_DL4: BuiltinAcp = BuiltinAcp { + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlCreate, + EntryClass::AccessControlDelete, + EntryClass::AccessControlModify, + EntryClass::AccessControlSearch + ], + name: "idm_acp_hp_oauth2_manage_priv", + uuid: UUID_IDM_ACP_OAUTH2_MANAGE_V1, + description: "Builtin IDM Control for managing oauth2 resource server integrations.", + receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_OAUTH2_ADMINS]), + target: BuiltinAcpTarget::Filter(ProtoFilter::And(vec![ + match_class_filter!(EntryClass::OAuth2ResourceServer), + FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone(), + ])), + search_attrs: vec![ + Attribute::Class, + Attribute::Description, + Attribute::DisplayName, + Attribute::OAuth2RsName, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsBasicSecret, + Attribute::OAuth2RsTokenKey, + Attribute::Es256PrivateKeyDer, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::Rs256PrivateKeyDer, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + modify_removed_attrs: vec![ + Attribute::Description, + Attribute::DisplayName, + Attribute::OAuth2RsName, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsBasicSecret, + Attribute::OAuth2RsTokenKey, + Attribute::Es256PrivateKeyDer, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::Rs256PrivateKeyDer, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + modify_present_attrs: vec![ + Attribute::Description, + Attribute::DisplayName, + Attribute::OAuth2RsName, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + create_attrs: vec![ + Attribute::Class, + Attribute::Description, + Attribute::DisplayName, + Attribute::OAuth2RsName, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + create_classes: vec![ + EntryClass::Object, + EntryClass::OAuth2ResourceServer, + EntryClass::OAuth2ResourceServerBasic, + EntryClass::OAuth2ResourceServerPublic, + ], + ..Default::default() + }; +} + lazy_static! { pub static ref IDM_ACP_DOMAIN_ADMIN_V1: BuiltinAcp = BuiltinAcp { classes: vec![ diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index 3af8a63a9..971dd1e8d 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -118,10 +118,12 @@ pub enum Attribute { NsUniqueId, NsAccountLock, OAuth2AllowInsecureClientDisablePkce, + OAuth2AllowLocalhostRedirect, OAuth2ConsentScopeMap, OAuth2JwtLegacyCryptoEnable, OAuth2PreferShortUsername, OAuth2RsBasicSecret, + OAuth2RsClaimMap, OAuth2RsImplicitScopes, OAuth2RsName, OAuth2RsOrigin, @@ -304,10 +306,12 @@ impl TryFrom for Attribute { ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE => { Attribute::OAuth2AllowInsecureClientDisablePkce } + ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT => Attribute::OAuth2AllowLocalhostRedirect, ATTR_OAUTH2_CONSENT_SCOPE_MAP => Attribute::OAuth2ConsentScopeMap, ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE => Attribute::OAuth2JwtLegacyCryptoEnable, ATTR_OAUTH2_PREFER_SHORT_USERNAME => Attribute::OAuth2PreferShortUsername, ATTR_OAUTH2_RS_BASIC_SECRET => Attribute::OAuth2RsBasicSecret, + ATTR_OAUTH2_RS_CLAIM_MAP => Attribute::OAuth2RsClaimMap, ATTR_OAUTH2_RS_IMPLICIT_SCOPES => Attribute::OAuth2RsImplicitScopes, ATTR_OAUTH2_RS_NAME => Attribute::OAuth2RsName, ATTR_OAUTH2_RS_ORIGIN => Attribute::OAuth2RsOrigin, @@ -465,10 +469,12 @@ impl From for &'static str { Attribute::OAuth2AllowInsecureClientDisablePkce => { ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE } + Attribute::OAuth2AllowLocalhostRedirect => ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT, Attribute::OAuth2ConsentScopeMap => ATTR_OAUTH2_CONSENT_SCOPE_MAP, Attribute::OAuth2JwtLegacyCryptoEnable => ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, Attribute::OAuth2PreferShortUsername => ATTR_OAUTH2_PREFER_SHORT_USERNAME, Attribute::OAuth2RsBasicSecret => ATTR_OAUTH2_RS_BASIC_SECRET, + Attribute::OAuth2RsClaimMap => ATTR_OAUTH2_RS_CLAIM_MAP, Attribute::OAuth2RsImplicitScopes => ATTR_OAUTH2_RS_IMPLICIT_SCOPES, Attribute::OAuth2RsName => ATTR_OAUTH2_RS_NAME, Attribute::OAuth2RsOrigin => ATTR_OAUTH2_RS_ORIGIN, diff --git a/server/lib/src/constants/mod.rs b/server/lib/src/constants/mod.rs index e140b8a71..186afad7e 100644 --- a/server/lib/src/constants/mod.rs +++ b/server/lib/src/constants/mod.rs @@ -46,12 +46,13 @@ pub type DomainVersion = u32; pub const DOMAIN_LEVEL_1: DomainVersion = 1; pub const DOMAIN_LEVEL_2: DomainVersion = 2; pub const DOMAIN_LEVEL_3: DomainVersion = 3; +pub const DOMAIN_LEVEL_4: DomainVersion = 4; // The minimum supported domain functional level pub const DOMAIN_MIN_LEVEL: DomainVersion = DOMAIN_LEVEL_2; // The target supported domain functional level -pub const DOMAIN_TGT_LEVEL: DomainVersion = DOMAIN_LEVEL_3; +pub const DOMAIN_TGT_LEVEL: DomainVersion = DOMAIN_LEVEL_4; // The maximum supported domain functional level -pub const DOMAIN_MAX_LEVEL: DomainVersion = DOMAIN_LEVEL_3; +pub const DOMAIN_MAX_LEVEL: DomainVersion = DOMAIN_LEVEL_4; // On test builds, define to 60 seconds #[cfg(test)] diff --git a/server/lib/src/constants/schema.rs b/server/lib/src/constants/schema.rs index c989c4b69..106b22ef0 100644 --- a/server/lib/src/constants/schema.rs +++ b/server/lib/src/constants/schema.rs @@ -322,12 +322,35 @@ pub static ref SCHEMA_ATTR_OAUTH2_RS_ORIGIN: SchemaAttribute = SchemaAttribute { pub static ref SCHEMA_ATTR_OAUTH2_RS_ORIGIN_LANDING: SchemaAttribute = SchemaAttribute { uuid: UUID_SCHEMA_ATTR_OAUTH2_RS_ORIGIN_LANDING, name: Attribute::OAuth2RsOriginLanding.into(), - description: "The landing page of an RS, that will automatically trigger the auth process.to_string().".to_string(), + description: "The landing page of an RS, that will automatically trigger the auth process".to_string(), syntax: SyntaxType::Url, ..Default::default() }; +// Introduced in DomainLevel4 +pub static ref SCHEMA_ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT_DL4: SchemaAttribute = SchemaAttribute { + uuid: UUID_SCHEMA_ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT, + name: Attribute::OAuth2AllowLocalhostRedirect.into(), + description: "Allow public clients associated to this RS to redirect to localhost".to_string(), + + syntax: SyntaxType::Boolean, + ..Default::default() +}; + +pub static ref SCHEMA_ATTR_OAUTH2_RS_CLAIM_MAP_DL4: SchemaAttribute = SchemaAttribute { + uuid: UUID_SCHEMA_ATTR_OAUTH2_RS_CLAIM_MAP, + name: Attribute::OAuth2RsClaimMap.into(), + description: + "A set of custom claims mapped to group memberships of accounts.".to_string(), + + index: vec![IndexType::Equality], + multivalue: true, + // CHANGE ME + syntax: SyntaxType::OauthClaimMap, + ..Default::default() +}; + pub static ref SCHEMA_ATTR_OAUTH2_RS_SCOPE_MAP: SchemaAttribute = SchemaAttribute { uuid: UUID_SCHEMA_ATTR_OAUTH2_RS_SCOPE_MAP, name: Attribute::OAuth2RsScopeMap.into(), @@ -828,6 +851,32 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_OAUTH2_RS_DL4: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_OAUTH2_RS, + name: EntryClass::OAuth2ResourceServer.into(), + description: "The class representing a configured Oauth2 Resource Server".to_string(), + + systemmay: vec![ + Attribute::Description.into(), + Attribute::OAuth2RsScopeMap.into(), + Attribute::OAuth2RsSupScopeMap.into(), + Attribute::Rs256PrivateKeyDer.into(), + Attribute::OAuth2JwtLegacyCryptoEnable.into(), + Attribute::OAuth2PreferShortUsername.into(), + Attribute::OAuth2RsOriginLanding.into(), + Attribute::Image.into(), + Attribute::OAuth2RsClaimMap.into(), + ], + systemmust: vec![ + Attribute::OAuth2RsName.into(), + Attribute::DisplayName.into(), + Attribute::OAuth2RsOrigin.into(), + Attribute::OAuth2RsTokenKey.into(), + Attribute::Es256PrivateKeyDer.into(), + ], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC, name: EntryClass::OAuth2ResourceServerBasic.into(), @@ -839,12 +888,22 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC: SchemaClass = SchemaClass { ..Default::default() }; - pub static ref SCHEMA_CLASS_OAUTH2_RS_PUBLIC: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_PUBLIC, name: EntryClass::OAuth2ResourceServerPublic.into(), - description: "The class representing a configured Oauth2 Resource Server with public clients and pkce verification".to_string(), + + systemexcludes: vec![EntryClass::OAuth2ResourceServerBasic.into()], + ..Default::default() +}; + +// Introduced in DomainLevel4 +pub static ref SCHEMA_CLASS_OAUTH2_RS_PUBLIC_DL4: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_PUBLIC, + name: EntryClass::OAuth2ResourceServerPublic.into(), + description: "The class representing a configured Oauth2 Resource Server with public clients and pkce verification".to_string(), + + systemmay: vec![Attribute::OAuth2AllowLocalhostRedirect.into()], systemexcludes: vec![EntryClass::OAuth2ResourceServerBasic.into()], ..Default::default() }; diff --git a/server/lib/src/constants/uuids.rs b/server/lib/src/constants/uuids.rs index f2ae373e1..27518b723 100644 --- a/server/lib/src/constants/uuids.rs +++ b/server/lib/src/constants/uuids.rs @@ -270,6 +270,10 @@ pub const UUID_SCHEMA_CLASS_ACCESS_CONTROL_TARGET_SCOPE: Uuid = pub const UUID_SCHEMA_ATTR_ENTRY_MANAGED_BY: Uuid = uuid!("00000000-0000-0000-0000-ffff00000156"); pub const UUID_SCHEMA_ATTR_UNIX_PASSWORD_IMPORT: Uuid = uuid!("00000000-0000-0000-0000-ffff00000157"); +pub const UUID_SCHEMA_ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT: Uuid = + uuid!("00000000-0000-0000-0000-ffff00000158"); +pub const UUID_SCHEMA_ATTR_OAUTH2_RS_CLAIM_MAP: Uuid = + uuid!("00000000-0000-0000-0000-ffff00000159"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. diff --git a/server/lib/src/idm/group.rs b/server/lib/src/idm/group.rs index e3403ed7f..df84b8be4 100644 --- a/server/lib/src/idm/group.rs +++ b/server/lib/src/idm/group.rs @@ -74,6 +74,10 @@ macro_rules! upg_from_account_e { } impl Group { + pub(crate) fn uuid(&self) -> Uuid { + self.uuid + } + pub fn try_from_account_entry_reduced<'a, TXN>( value: &Entry, qs: &mut TXN, diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index 19a1d423a..268589848 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -4,6 +4,7 @@ //! integrations, which are then able to be used an accessed from the IDM layer //! for operations involving OAuth2 authentication processing. +use std::collections::btree_map::Entry as BTreeEntry; use std::collections::{BTreeMap, BTreeSet}; use std::convert::TryFrom; use std::fmt; @@ -40,7 +41,7 @@ use crate::idm::server::{ IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction, IdmServerTransaction, }; use crate::prelude::*; -use crate::value::{Oauth2Session, SessionState, OAUTHSCOPE_RE}; +use crate::value::{Oauth2Session, OauthClaimMapJoin, SessionState, OAUTHSCOPE_RE}; #[derive(Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "snake_case")] @@ -202,7 +203,9 @@ enum OauthRSType { enable_pkce: bool, }, // Public clients must have pkce. - Public, + Public { + allow_localhost_redirect: bool, + }, } impl std::fmt::Debug for OauthRSType { @@ -212,7 +215,11 @@ impl std::fmt::Debug for OauthRSType { OauthRSType::Basic { enable_pkce, .. } => { ds.field("type", &"basic").field("pkce", enable_pkce) } - OauthRSType::Public => ds.field("type", &"public"), + OauthRSType::Public { + allow_localhost_redirect, + } => ds + .field("type", &"public") + .field("allow_localhost_redirect", allow_localhost_redirect), }; ds.finish() } @@ -224,6 +231,40 @@ enum Oauth2JwsSigner { RS256 { signer: JwsRs256Signer }, } +#[derive(Clone, Debug)] +struct ClaimValue { + join: OauthClaimMapJoin, + values: BTreeSet, +} + +impl ClaimValue { + fn merge(&mut self, other: &Self) { + self.values.extend(other.values.iter().cloned()) + } + + fn to_json_value(&self) -> serde_json::Value { + let join_char = match self.join { + OauthClaimMapJoin::CommaSeparatedValue => ',', + OauthClaimMapJoin::SpaceSeparatedValue => ' ', + OauthClaimMapJoin::JsonArray => { + let arr: Vec<_> = self + .values + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(); + + // This shortcuts out. + return serde_json::Value::Array(arr); + } + }; + + let joined = str_concat!(&self.values, join_char); + + serde_json::Value::String(joined) + } +} + #[derive(Clone)] pub struct Oauth2RS { name: String, @@ -231,13 +272,12 @@ pub struct Oauth2RS { uuid: Uuid, origin: Origin, origin_https: bool, + claim_map: BTreeMap>, scope_maps: BTreeMap>, sup_scope_maps: BTreeMap>, // Our internal exchange encryption material for this rs. token_fernet: Fernet, - jws_signer: Oauth2JwsSigner, - // For oidc we also need our issuer url. iss: Url, // For discovery we need to build and keep a number of values. @@ -262,6 +302,7 @@ impl std::fmt::Debug for Oauth2RS { .field("origin", &self.origin) .field("scope_maps", &self.scope_maps) .field("sup_scope_maps", &self.sup_scope_maps) + .field("claim_map", &self.claim_map) .field("has_custom_image", &self.has_custom_image) .finish() } @@ -352,7 +393,13 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { enable_pkce, } } else if ent.attribute_equality(Attribute::Class, &EntryClass::OAuth2ResourceServerPublic.into()) { - OauthRSType::Public + let allow_localhost_redirect = ent + .get_ava_single_bool(Attribute::OAuth2AllowLocalhostRedirect) + .unwrap_or(false); + + OauthRSType::Public { + allow_localhost_redirect + } } else { error!("Missing class determining OAuth2 rs type"); return Err(OperationError::InvalidEntryState); @@ -400,6 +447,51 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { .cloned() .unwrap_or_default(); + let e_claim_maps = ent + .get_ava_set(Attribute::OAuth2RsClaimMap) + .and_then(|vs| vs.as_oauthclaim_map()); + + // โš ๏ธ Claim Maps as they are stored in the DB are optimised + // for referential integrity and user interaction. However we + // need to "invert" these for fast lookups during actual + // operation of the oauth2 client. + let claim_map = if let Some(e_claim_maps) = e_claim_maps { + let mut claim_map = BTreeMap::default(); + + for (claim_name, claim_mapping) in e_claim_maps.iter() { + for (group_uuid, claim_values) in claim_mapping.values().iter() { + // We always insert/append here because the outer claim_name has + // to be unique. + match claim_map.entry(*group_uuid) { + BTreeEntry::Vacant(e) => { + e.insert( + vec![ + ( + claim_name.clone(), ClaimValue { + join: claim_mapping.join(), + values: claim_values.clone() + } + ) + ] + ); + } + BTreeEntry::Occupied(mut e) => { + e.get_mut().push(( + claim_name.clone(), ClaimValue { + join: claim_mapping.join(), + values: claim_values.clone() + } + )); + } + } + } + } + + claim_map + } else { + BTreeMap::default() + }; + trace!("{}", Attribute::OAuth2JwtLegacyCryptoEnable.as_ref()); let jws_signer = if ent.get_ava_single_bool(Attribute::OAuth2JwtLegacyCryptoEnable).unwrap_or(false) { trace!("{}", Attribute::Rs256PrivateKeyDer); @@ -473,6 +565,7 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { origin_https, scope_maps, sup_scope_maps, + claim_map, token_fernet, jws_signer, iss, @@ -528,7 +621,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } } // Relies on the token to be valid. - OauthRSType::Public => {} + OauthRSType::Public { .. } => {} }; // We are authenticated! Yay! Now we can actually check things ... @@ -654,7 +747,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } } // Relies on the token to be valid - no further action needed. - OauthRSType::Public => {} + OauthRSType::Public { .. } => {} }; // We are authenticated! Yay! Now we can actually check things ... @@ -807,7 +900,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { let require_pkce = match &o2rs.type_ { OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, - OauthRSType::Public => true, + OauthRSType::Public { .. } => true, }; // If we have a verifier present, we MUST assert that a code challenge is present! @@ -1083,7 +1176,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { }; let s_claims = s_claims_for_account(o2rs, &account, &scopes); - let extra_claims = extra_claims_for_account(&account, &scopes); + let extra_claims = extra_claims_for_account(&account, &o2rs.claim_map, &scopes); let oidc = OidcToken { iss, @@ -1229,7 +1322,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } } // Relies on the token to be valid. - OauthRSType::Public => {} + OauthRSType::Public { .. } => {} }; o2rs.token_fernet @@ -1289,8 +1382,24 @@ impl<'a> IdmServerProxyReadTransaction<'a> { Oauth2Error::InvalidClientId })?; - // redirect_uri must be part of the client_id origin. - if auth_req.redirect_uri.origin() != o2rs.origin { + let allow_localhost_redirect = match &o2rs.type_ { + OauthRSType::Basic { .. } => false, + OauthRSType::Public { + allow_localhost_redirect, + } => *allow_localhost_redirect, + }; + + let localhost_redirect = auth_req + .redirect_uri + .domain() + .map(|domain| domain == "localhost") + .unwrap_or_default(); + + // redirect_uri must be part of the client_id origin, unless the client is public and then it MAY + // be localhost. + if !(auth_req.redirect_uri.origin() == o2rs.origin + || (allow_localhost_redirect && localhost_redirect)) + { admin_warn!( origin = ?o2rs.origin, "Invalid OAuth2 redirect_uri (must be related to origin {:?}) - got {:?}", @@ -1300,7 +1409,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { return Err(Oauth2Error::InvalidOrigin); } - if o2rs.origin_https && auth_req.redirect_uri.scheme() != "https" { + if !localhost_redirect && o2rs.origin_https && auth_req.redirect_uri.scheme() != "https" { admin_warn!( origin = ?o2rs.origin, "Invalid OAuth2 redirect_uri (must be https for secure origin) - got {:?}", auth_req.redirect_uri.scheme() @@ -1310,7 +1419,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { let require_pkce = match &o2rs.type_ { OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, - OauthRSType::Public => true, + OauthRSType::Public { .. } => true, }; let code_challenge = if let Some(pkce_request) = &auth_req.pkce_request { @@ -1634,7 +1743,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { } } // Relies on the token to be valid. - OauthRSType::Public => {} + OauthRSType::Public { .. } => {} }; // We are authenticated! Yay! Now we can actually check things ... @@ -1799,7 +1908,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { let iss = o2rs.iss.clone(); let s_claims = s_claims_for_account(o2rs, &account, &scopes); - let extra_claims = extra_claims_for_account(&account, &scopes); + let extra_claims = extra_claims_for_account(&account, &o2rs.claim_map, &scopes); let exp = expiry.unix_timestamp(); // ==== good to generate response ==== @@ -1993,17 +2102,53 @@ fn s_claims_for_account( ..Default::default() } } + fn extra_claims_for_account( account: &Account, + + claim_map: &BTreeMap>, + scopes: &BTreeSet, ) -> BTreeMap { let mut extra_claims = BTreeMap::new(); + + let mut account_claims: BTreeMap<&str, ClaimValue> = BTreeMap::new(); + + // for each group + for group_uuid in account.groups.iter().map(|g| g.uuid()) { + // Does this group have any custom claims? + if let Some(claim) = claim_map.get(&group_uuid) { + // If so, iterate over the set of claims and values. + for (claim_name, claim_value) in claim.iter() { + // Does this claim name already exist in our in-progress map? + match account_claims.entry(claim_name.as_str()) { + BTreeEntry::Vacant(e) => { + e.insert(claim_value.clone()); + } + BTreeEntry::Occupied(mut e) => { + let mut_claim_value = e.get_mut(); + // Merge the extra details into this. + mut_claim_value.merge(claim_value); + } + } + } + } + } + + // Now, flatten all these into the final structure. + for (claim_name, claim_value) in account_claims { + extra_claims.insert(claim_name.to_string(), claim_value.to_json_value()); + } + if scopes.contains(&"groups".to_string()) { extra_claims.insert( "groups".to_string(), account.groups.iter().map(|x| x.to_proto().uuid).collect(), ); } + + trace!(?extra_claims); + extra_claims } @@ -2041,6 +2186,7 @@ mod tests { use crate::idm::oauth2::{AuthoriseResponse, Oauth2Error}; use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::prelude::*; + use crate::value::OauthClaimMapJoin; use crate::value::SessionState; use crate::credential::Credential; @@ -2397,8 +2543,6 @@ mod tests { let idms_prox_read = idms.proxy_read().await; - // Get an ident/uat for now. - // == Setup the authorisation request let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); @@ -4298,7 +4442,6 @@ mod tests { assert!(idms_prox_write.commit().is_ok()); } - #[idm_test] // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.8 // // It was reported we were vulnerable to this attack, but that isn't the case. First @@ -4318,6 +4461,7 @@ mod tests { // exchange that code exchange with out the verifier, but I'm not sure what damage that would // lead to? Regardless, we test for and close off that possible hole in this test. // + #[idm_test] async fn test_idm_oauth2_1076_pkce_downgrade( idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed, @@ -4935,4 +5079,328 @@ mod tests { ); assert!(token_req.is_ok()); } + + #[idm_test] + async fn test_idm_oauth2_custom_claims(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, _uat, ident, oauth2_rs_uuid) = + setup_oauth2_resource_server_basic(idms, ct, true, false, false).await; + + // Setup custom claim maps here. + let mut idms_prox_write = idms.proxy_write(ct).await; + + let modlist = ModifyList::new_list(vec![ + // Member of a claim map. + Modify::Present( + Attribute::OAuth2RsClaimMap.into(), + Value::OauthClaimMap( + "custom_a".to_string(), + OauthClaimMapJoin::CommaSeparatedValue, + ), + ), + Modify::Present( + Attribute::OAuth2RsClaimMap.into(), + Value::OauthClaimValue( + "custom_a".to_string(), + UUID_SYSTEM_ADMINS, + btreeset!["value_a".to_string()], + ), + ), + // If you are a member of two groups, the claim maps merge. + Modify::Present( + Attribute::OAuth2RsClaimMap.into(), + Value::OauthClaimValue( + "custom_a".to_string(), + UUID_IDM_ALL_ACCOUNTS, + btreeset!["value_b".to_string()], + ), + ), + // Map with a different seperator + Modify::Present( + Attribute::OAuth2RsClaimMap.into(), + Value::OauthClaimMap( + "custom_b".to_string(), + OauthClaimMapJoin::SpaceSeparatedValue, + ), + ), + Modify::Present( + Attribute::OAuth2RsClaimMap.into(), + Value::OauthClaimValue( + "custom_b".to_string(), + UUID_SYSTEM_ADMINS, + btreeset!["value_a".to_string()], + ), + ), + Modify::Present( + Attribute::OAuth2RsClaimMap.into(), + Value::OauthClaimValue( + "custom_b".to_string(), + UUID_IDM_ALL_ACCOUNTS, + btreeset!["value_b".to_string()], + ), + ), + // Not a member of the claim map. + Modify::Present( + Attribute::OAuth2RsClaimMap.into(), + Value::OauthClaimValue( + "custom_b".to_string(), + UUID_IDM_ADMINS, + btreeset!["value_c".to_string()], + ), + ), + ]); + + assert!(idms_prox_write + .qs_write + .internal_modify( + &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(oauth2_rs_uuid))), + &modlist, + ) + .is_ok()); + + assert!(idms_prox_write.commit().is_ok()); + + // Claim maps setup, lets go. + let client_authz = + Some(general_purpose::STANDARD.encode(format!("test_resource_server:{secret}"))); + + let idms_prox_read = idms.proxy_read().await; + + let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble"); + + let consent_request = good_authorisation_request!( + idms_prox_read, + &ident, + ct, + code_challenge, + OAUTH2_SCOPE_OPENID.to_string() + ); + + 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 + drop(idms_prox_read); + let mut idms_prox_write = idms.proxy_write(ct).await; + + let permit_success = idms_prox_write + .check_oauth2_authorise_permit(&ident, &consent_token, ct) + .expect("Failed to perform OAuth2 permit"); + + // == Submit the token exchange code. + let token_req: AccessTokenRequest = GrantTypeReq::AuthorizationCode { + code: permit_success.code, + redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), + // From the first step. + code_verifier, + } + .into(); + + let token_response = idms_prox_write + .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) + .expect("Failed to perform OAuth2 token exchange"); + + // ๐ŸŽ‰ We got a token! + assert!(token_response.token_type == "Bearer"); + + let id_token = token_response.id_token.expect("No id_token in response!"); + let access_token = token_response.access_token; + + // Get the read txn for inspecting the tokens + assert!(idms_prox_write.commit().is_ok()); + + let mut idms_prox_read = idms.proxy_read().await; + + let mut jwkset = idms_prox_read + .oauth2_openid_publickey("test_resource_server") + .expect("Failed to get public key"); + + let public_jwk = jwkset.keys.pop().expect("no such jwk"); + + let jws_validator = + JwsEs256Verifier::try_from(&public_jwk).expect("failed to build validator"); + + let oidc_unverified = + OidcUnverified::from_str(&id_token).expect("Failed to parse id_token"); + + let iat = ct.as_secs() as i64; + + let oidc = jws_validator + .verify(&oidc_unverified) + .unwrap() + .verify_exp(iat) + .expect("Failed to verify oidc"); + + // 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)); + // Previously this was the auth session but it's now inline with the access token expiry. + assert!(oidc.exp == iat + (OAUTH2_ACCESS_TOKEN_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.is_none()); + 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![OAUTH2_SCOPE_OPENID.to_string(), "supplement".to_string()] + ); + + assert_eq!( + oidc.claims.get("custom_a").and_then(|v| v.as_str()), + Some("value_a,value_b") + ); + assert_eq!( + oidc.claims.get("custom_b").and_then(|v| v.as_str()), + Some("value_a value_b") + ); + + // 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 == Some("abcdef".to_string())); + 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_eq!(oidc.claims, userinfo.claims); + + // Check the oauth2 introspect bits. + let intr_request = AccessTokenIntrospectRequest { + token: access_token, + 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"); + + eprintln!("๐Ÿ‘‰ {intr_response:?}"); + assert!(intr_response.active); + assert!(intr_response.scope.as_deref() == Some("openid supplement")); + 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)); + // Introspect doesn't have custom claims. + + drop(idms_prox_read); + } + + #[idm_test] + async fn test_idm_oauth2_public_allow_localhost_redirect( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (_uat, ident, oauth2_rs_uuid) = setup_oauth2_resource_server_public(idms, ct).await; + + let mut idms_prox_write = idms.proxy_write(ct).await; + + let modlist = ModifyList::new_list(vec![Modify::Present( + Attribute::OAuth2AllowLocalhostRedirect.into(), + Value::Bool(true), + )]); + + assert!(idms_prox_write + .qs_write + .internal_modify( + &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(oauth2_rs_uuid))), + &modlist, + ) + .is_ok()); + + assert!(idms_prox_write.commit().is_ok()); + + let idms_prox_read = idms.proxy_read().await; + + // == Setup the authorisation request + 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("http://localhost:8765/oauth2/result").unwrap(), + scope: OAUTH2_SCOPE_OPENID.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, &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!(); + }; + + // == Manually submit the consent token to the permit for the permit_success + drop(idms_prox_read); + let mut idms_prox_write = idms.proxy_write(ct).await; + + let permit_success = idms_prox_write + .check_oauth2_authorise_permit(&ident, &consent_token, ct) + .expect("Failed to perform OAuth2 permit"); + + // Check we are reflecting the CSRF properly. + assert!(permit_success.state == "123"); + + // == Submit the token exchange code. + let token_req = AccessTokenRequest { + grant_type: GrantTypeReq::AuthorizationCode { + code: permit_success.code, + redirect_uri: Url::parse("http://localhost:8765/oauth2/result").unwrap(), + // From the first step. + code_verifier, + }, + client_id: Some("test_resource_server".to_string()), + client_secret: None, + }; + + let token_response = idms_prox_write + .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"); + + assert!(idms_prox_write.commit().is_ok()); + } } diff --git a/server/lib/src/macros.rs b/server/lib/src/macros.rs index d218fbe03..7afdce5f4 100644 --- a/server/lib/src/macros.rs +++ b/server/lib/src/macros.rs @@ -652,3 +652,24 @@ macro_rules! limmediate_warning { eprint!($($arg)*) }) } + +macro_rules! str_concat { + ($str_iter:expr, $join_char:expr) => {{ + // Sub 1 here because we need N minus 1 join chars + let max_join_chars: usize = $str_iter.len() - 1; + let data_len: usize = $str_iter + .iter() + .map(|s| s.len()) + .fold(max_join_chars, |acc, x| acc + x); + + let mut joined = String::with_capacity(data_len); + for (i, value) in $str_iter.iter().enumerate() { + joined.push_str(value); + if i < max_join_chars { + joined.push($join_char); + } + } + + joined + }}; +} diff --git a/server/lib/src/plugins/attrunique.rs b/server/lib/src/plugins/attrunique.rs index e2085c5c1..1979015b2 100644 --- a/server/lib/src/plugins/attrunique.rs +++ b/server/lib/src/plugins/attrunique.rs @@ -177,7 +177,7 @@ fn enforce_unique( })?; // A conflict was found! - if let Some(conflict_cand_zero) = conflict_cand.get(0) { + if let Some(conflict_cand_zero) = conflict_cand.first() { if cand_query.len() >= 2 { // Continue to split to isolate. let mid = cand_query.len() / 2; diff --git a/server/lib/src/plugins/refint.rs b/server/lib/src/plugins/refint.rs index 27e092c99..5133c700f 100644 --- a/server/lib/src/plugins/refint.rs +++ b/server/lib/src/plugins/refint.rs @@ -454,7 +454,7 @@ mod tests { use crate::event::CreateEvent; use crate::prelude::*; - use crate::value::{Oauth2Session, Session, SessionState}; + use crate::value::{Oauth2Session, OauthClaimMapJoin, Session, SessionState}; use time::OffsetDateTime; use crate::credential::Credential; @@ -1253,4 +1253,79 @@ mod tests { assert!(server_txn.commit().is_ok()); } + + #[test] + fn test_delete_remove_reference_oauth2_claim_map() { + let ea: Entry = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + ( + Attribute::Class, + EntryClass::OAuth2ResourceServer.to_value() + ), + ( + Attribute::Class, + EntryClass::OAuth2ResourceServerPublic.to_value() + ), + ( + Attribute::OAuth2RsName, + Value::new_iname("test_resource_server") + ), + ( + Attribute::DisplayName, + Value::new_utf8s("test_resource_server") + ), + ( + Attribute::OAuth2RsOrigin, + Value::new_url_s("https://demo.example.com").unwrap() + ), + ( + Attribute::OAuth2RsClaimMap, + Value::OauthClaimMap( + "custom_a".to_string(), + OauthClaimMapJoin::CommaSeparatedValue, + ) + ), + ( + Attribute::OAuth2RsClaimMap, + Value::OauthClaimValue( + "custom_a".to_string(), + Uuid::parse_str(TEST_TESTGROUP_B_UUID).unwrap(), + btreeset!["value_a".to_string()], + ) + ) + ); + + let eb: Entry = entry_init!( + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("testgroup")), + ( + Attribute::Uuid, + Value::Uuid(Uuid::parse_str(TEST_TESTGROUP_B_UUID).unwrap()) + ), + (Attribute::Description, Value::new_utf8s("testgroup")) + ); + + let preload = vec![ea, eb]; + + run_delete_test!( + Ok(()), + preload, + filter!(f_eq(Attribute::Name, PartialValue::new_iname("testgroup"))), + None, + |qs: &mut QueryServerWriteTransaction| { + let cands = qs + .internal_search(filter!(f_eq( + Attribute::OAuth2RsName, + PartialValue::new_iname("test_resource_server") + ))) + .expect("Internal search failure"); + let ue = cands.first().expect("No entry"); + + assert!(ue + .get_ava_set(Attribute::OAuth2RsClaimMap) + .and_then(|vs| vs.as_oauthclaim_map()) + .is_none()) + } + ); + } } diff --git a/server/lib/src/repl/proto.rs b/server/lib/src/repl/proto.rs index 6927f65ae..711cef36e 100644 --- a/server/lib/src/repl/proto.rs +++ b/server/lib/src/repl/proto.rs @@ -2,6 +2,7 @@ use super::cid::Cid; use super::entry::EntryChangeState; use super::entry::State; use crate::be::dbvalue::DbValueImage; +use crate::be::dbvalue::DbValueOauthClaimMapJoinV1; use crate::entry::Eattrs; use crate::prelude::*; use crate::schema::{SchemaReadTransaction, SchemaTransaction}; @@ -253,6 +254,13 @@ pub struct ReplOauthScopeMapV1 { pub data: BTreeSet, } +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct ReplOauthClaimMapV1 { + pub name: String, + pub join: DbValueOauthClaimMapJoinV1, + pub values: BTreeMap>, +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct ReplOauth2SessionV1 { pub refer: Uuid, @@ -412,6 +420,9 @@ pub enum ReplAttrV1 { OauthScopeMap { set: Vec, }, + OauthClaimMap { + set: Vec, + }, Oauth2Session { set: Vec, }, diff --git a/server/lib/src/schema.rs b/server/lib/src/schema.rs index 8ba89a143..3028640db 100644 --- a/server/lib/src/schema.rs +++ b/server/lib/src/schema.rs @@ -213,6 +213,12 @@ impl SchemaAttribute { SyntaxType::Url => matches!(v, PartialValue::Url(_)), SyntaxType::OauthScope => matches!(v, PartialValue::OauthScope(_)), SyntaxType::OauthScopeMap => matches!(v, PartialValue::Refer(_)), + SyntaxType::OauthClaimMap => { + matches!(v, PartialValue::Iutf8(_)) + || matches!(v, PartialValue::Refer(_)) + || matches!(v, PartialValue::OauthClaimValue(_, _, _)) + || matches!(v, PartialValue::OauthClaim(_, _)) + } SyntaxType::PrivateBinary => matches!(v, PartialValue::PrivateBinary), SyntaxType::IntentToken => matches!(v, PartialValue::IntentToken(_)), SyntaxType::Passkey => matches!(v, PartialValue::Passkey(_)), @@ -270,6 +276,10 @@ impl SchemaAttribute { SyntaxType::Url => matches!(v, Value::Url(_)), SyntaxType::OauthScope => matches!(v, Value::OauthScope(_)), SyntaxType::OauthScopeMap => matches!(v, Value::OauthScopeMap(_, _)), + SyntaxType::OauthClaimMap => { + matches!(v, Value::OauthClaimValue(_, _, _)) + || matches!(v, Value::OauthClaimMap(_, _)) + } SyntaxType::PrivateBinary => matches!(v, Value::PrivateBinary(_)), SyntaxType::IntentToken => matches!(v, Value::IntentToken(_, _)), SyntaxType::Passkey => matches!(v, Value::Passkey(_, _, _)), @@ -762,6 +772,7 @@ impl<'a> SchemaWriteTransaction<'a> { // Update the unique and ref caches. if a.syntax == SyntaxType::ReferenceUuid || a.syntax == SyntaxType::OauthScopeMap || + a.syntax == SyntaxType::OauthClaimMap || // So that when an rs is removed we trigger removal of the sessions. a.syntax == SyntaxType::Oauth2Session // May not need to be a ref type since it doesn't have external links/impact? diff --git a/server/lib/src/server/migrations.rs b/server/lib/src/server/migrations.rs index a3c112648..d57efd7bd 100644 --- a/server/lib/src/server/migrations.rs +++ b/server/lib/src/server/migrations.rs @@ -706,6 +706,39 @@ impl<'a> QueryServerWriteTransaction<'a> { }) } + #[instrument(level = "info", skip_all)] + /// Migrations for Oauth to support multiple origins, and custom claims. + pub fn migrate_domain_3_to_4(&mut self) -> Result<(), OperationError> { + let idm_schema_attrs = [ + SCHEMA_ATTR_OAUTH2_RS_CLAIM_MAP_DL4.clone().into(), + SCHEMA_ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT_DL4 + .clone() + .into(), + ]; + + idm_schema_attrs + .into_iter() + .try_for_each(|entry| self.internal_migrate_or_create(entry)) + .map_err(|err| { + error!(?err, "migrate_domain_3_to_4 -> Error"); + err + })?; + + let idm_schema_classes = [ + SCHEMA_CLASS_OAUTH2_RS_DL4.clone().into(), + SCHEMA_CLASS_OAUTH2_RS_PUBLIC_DL4.clone().into(), + IDM_ACP_OAUTH2_MANAGE_DL4.clone().into(), + ]; + + idm_schema_classes + .into_iter() + .try_for_each(|entry| self.internal_migrate_or_create(entry)) + .map_err(|err| { + error!(?err, "migrate_domain_3_to_4 -> Error"); + err + }) + } + #[instrument(level = "info", skip_all)] pub fn initialise_schema_core(&mut self) -> Result<(), OperationError> { admin_debug!("initialise_schema_core -> start ..."); diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index 1c409ebd2..f17c4211a 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -583,6 +583,7 @@ pub trait QueryServerTransaction<'a> { SyntaxType::WebauthnAttestationCaList => Value::new_webauthn_attestation_ca_list(value) .ok_or_else(|| OperationError::InvalidAttribute("Invalid Webauthn Attestation CA List".to_string())), SyntaxType::OauthScopeMap => Err(OperationError::InvalidAttribute("Oauth Scope Maps can not be supplied through modification - please use the IDM api".to_string())), + SyntaxType::OauthClaimMap => Err(OperationError::InvalidAttribute("Oauth Claim Maps can not be supplied through modification - please use the IDM api".to_string())), SyntaxType::PrivateBinary => Err(OperationError::InvalidAttribute("Private Binary Values can not be supplied through modification".to_string())), SyntaxType::IntentToken => Err(OperationError::InvalidAttribute("Intent Token Values can not be supplied through modification".to_string())), SyntaxType::Passkey => Err(OperationError::InvalidAttribute("Passkey Values can not be supplied through modification".to_string())), @@ -657,6 +658,11 @@ pub trait QueryServerTransaction<'a> { let un = self.name_to_uuid(value).unwrap_or(UUID_DOES_NOT_EXIST); Ok(PartialValue::Refer(un)) } + SyntaxType::OauthClaimMap => self + .name_to_uuid(value) + .map(PartialValue::Refer) + .or_else(|_| Ok(PartialValue::new_iutf8(value))), + SyntaxType::JsonFilter => { PartialValue::new_json_filter_s(value).ok_or_else(|| { OperationError::InvalidAttribute("Invalid Filter syntax".to_string()) @@ -1622,6 +1628,10 @@ impl<'a> QueryServerWriteTransaction<'a> { self.migrate_domain_2_to_3()?; } + if previous_version <= DOMAIN_LEVEL_3 && domain_info_version >= DOMAIN_LEVEL_4 { + self.migrate_domain_3_to_4()?; + } + Ok(()) } diff --git a/server/lib/src/value.rs b/server/lib/src/value.rs index e23b52277..a0b619f5a 100644 --- a/server/lib/src/value.rs +++ b/server/lib/src/value.rs @@ -32,6 +32,7 @@ use webauthn_rs::prelude::{ }; use crate::be::dbentry::DbIdentSpn; +use crate::be::dbvalue::DbValueOauthClaimMapJoinV1; use crate::credential::{totp::Totp, Credential}; use crate::prelude::*; use crate::repl::cid::Cid; @@ -264,6 +265,7 @@ pub enum SyntaxType { Image = 34, CredentialType = 35, WebauthnAttestationCaList = 36, + OauthClaimMap = 37, } impl TryFrom<&str> for SyntaxType { @@ -309,6 +311,7 @@ impl TryFrom<&str> for SyntaxType { "EC_KEY_PRIVATE" => Ok(SyntaxType::EcKeyPrivate), "CREDENTIAL_TYPE" => Ok(SyntaxType::CredentialType), "WEBAUTHN_ATTESTATION_CA_LIST" => Ok(SyntaxType::WebauthnAttestationCaList), + "OAUTH_CLAIM_MAP" => Ok(SyntaxType::OauthClaimMap), _ => Err(()), } } @@ -354,6 +357,7 @@ impl fmt::Display for SyntaxType { SyntaxType::Image => "IMAGE", SyntaxType::CredentialType => "CREDENTIAL_TYPE", SyntaxType::WebauthnAttestationCaList => "WEBAUTHN_ATTESTATION_CA_LIST", + SyntaxType::OauthClaimMap => "OAUTH_CLAIM_MAP", }) } } @@ -471,6 +475,9 @@ pub enum PartialValue { /// We compare on the value hash Image(String), CredentialType(CredentialType), + + OauthClaim(String, Uuid), + OauthClaimValue(String, Uuid, String), } impl From for PartialValue { @@ -833,8 +840,6 @@ impl PartialValue { PartialValue::SecretValue | PartialValue::PrivateBinary => "_".to_string(), PartialValue::Spn(name, realm) => format!("{name}@{realm}"), PartialValue::Uint32(u) => u.to_string(), - // This will never work, we don't allow equality searching on Cid's - PartialValue::Cid(_) => "_".to_string(), PartialValue::DateTime(odt) => { debug_assert!(odt.offset() == time::UtcOffset::UTC); #[allow(clippy::expect_used)] @@ -849,6 +854,11 @@ impl PartialValue { PartialValue::UiHint(u) => (*u as u16).to_string(), PartialValue::Image(imagehash) => imagehash.to_owned(), PartialValue::CredentialType(ct) => ct.to_string(), + // This will never work, we don't allow equality searching on Cid's + PartialValue::Cid(_) => "_".to_string(), + // We don't allow searching on claim/uuid pairs. + PartialValue::OauthClaim(_, _) => "_".to_string(), + PartialValue::OauthClaimValue(_, _, _) => "_".to_string(), } } @@ -982,6 +992,42 @@ impl fmt::Debug for Session { } } +#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] +pub enum OauthClaimMapJoin { + CommaSeparatedValue, + SpaceSeparatedValue, + #[default] + JsonArray, +} + +impl From for OauthClaimMapJoin { + fn from(value: DbValueOauthClaimMapJoinV1) -> OauthClaimMapJoin { + match value { + DbValueOauthClaimMapJoinV1::CommaSeparatedValue => { + OauthClaimMapJoin::CommaSeparatedValue + } + DbValueOauthClaimMapJoinV1::SpaceSeparatedValue => { + OauthClaimMapJoin::SpaceSeparatedValue + } + DbValueOauthClaimMapJoinV1::JsonArray => OauthClaimMapJoin::JsonArray, + } + } +} + +impl From for DbValueOauthClaimMapJoinV1 { + fn from(value: OauthClaimMapJoin) -> DbValueOauthClaimMapJoinV1 { + match value { + OauthClaimMapJoin::CommaSeparatedValue => { + DbValueOauthClaimMapJoinV1::CommaSeparatedValue + } + OauthClaimMapJoin::SpaceSeparatedValue => { + DbValueOauthClaimMapJoinV1::SpaceSeparatedValue + } + OauthClaimMapJoin::JsonArray => DbValueOauthClaimMapJoinV1::JsonArray, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Oauth2Session { pub parent: Uuid, @@ -1046,6 +1092,9 @@ pub enum Value { Image(ImageValue), CredentialType(CredentialType), WebauthnAttestationCaList(AttestationCaList), + + OauthClaimValue(String, Uuid, BTreeSet), + OauthClaimMap(String, OauthClaimMapJoin), } impl PartialEq for Value { @@ -1512,6 +1561,14 @@ impl Value { } } + pub fn new_oauthclaimmap(n: String, u: Uuid, c: BTreeSet) -> Option { + if OAUTHSCOPE_RE.is_match(&n) && c.iter().all(|s| OAUTHSCOPE_RE.is_match(s)) { + Some(Value::OauthClaimValue(n, u, c)) + } else { + None + } + } + pub fn is_oauthscopemap(&self) -> bool { matches!(&self, Value::OauthScopeMap(_, _)) } @@ -1841,6 +1898,11 @@ impl Value { Value::OauthScope(s) => OAUTHSCOPE_RE.is_match(s), Value::OauthScopeMap(_, m) => m.iter().all(|s| OAUTHSCOPE_RE.is_match(s)), + Value::OauthClaimMap(name, _) => OAUTHSCOPE_RE.is_match(name), + Value::OauthClaimValue(name, _, value) => { + OAUTHSCOPE_RE.is_match(name) && value.iter().all(|s| OAUTHSCOPE_RE.is_match(s)) + } + Value::PhoneNumber(_, _) => true, Value::Address(_) => true, diff --git a/server/lib/src/valueset/mod.rs b/server/lib/src/valueset/mod.rs index 0c44e2fbd..440bd204e 100644 --- a/server/lib/src/valueset/mod.rs +++ b/server/lib/src/valueset/mod.rs @@ -42,7 +42,9 @@ pub use self::iutf8::ValueSetIutf8; pub use self::json::ValueSetJsonFilter; pub use self::jws::{ValueSetJwsKeyEs256, ValueSetJwsKeyRs256}; pub use self::nsuniqueid::ValueSetNsUniqueId; -pub use self::oauth::{ValueSetOauthScope, ValueSetOauthScopeMap}; +pub use self::oauth::{ + OauthClaimMapping, ValueSetOauthClaimMap, ValueSetOauthScope, ValueSetOauthScopeMap, +}; pub use self::restricted::ValueSetRestricted; pub use self::secret::ValueSetSecret; pub use self::session::{ValueSetApiToken, ValueSetOauth2Session, ValueSetSession}; @@ -365,6 +367,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { None } + fn as_oauthclaim_map(&self) -> Option<&BTreeMap> { + debug_assert!(false); + None + } + fn to_value_single(&self) -> Option { if self.len() != 1 { None @@ -676,6 +683,8 @@ pub fn from_result_value_iter( | Value::Session(_, _) | Value::ApiToken(_, _) | Value::Oauth2Session(_, _) + | Value::OauthClaimMap(_, _) + | Value::OauthClaimValue(_, _, _) | Value::JwsKeyEs256(_) | Value::JwsKeyRs256(_) => { debug_assert!(false); @@ -740,6 +749,10 @@ pub fn from_value_iter(mut iter: impl Iterator) -> Result { ValueSetWebauthnAttestationCaList::new(ca_list) } + Value::OauthClaimMap(name, join) => ValueSetOauthClaimMap::new(name, join), + Value::OauthClaimValue(name, group, claims) => { + ValueSetOauthClaimMap::new_value(name, group, claims) + } Value::PhoneNumber(_, _) => { debug_assert!(false); return Err(OperationError::InvalidValueState); @@ -800,6 +813,7 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result { ValueSetWebauthnAttestationCaList::from_dbvs2(ca_list) } + DbValueSetV2::OauthClaimMap(set) => ValueSetOauthClaimMap::from_dbvs2(set), } } @@ -849,5 +863,6 @@ pub fn from_repl_v1(rv1: &ReplAttrV1) -> Result { ReplAttrV1::WebauthnAttestationCaList { ca_list } => { ValueSetWebauthnAttestationCaList::from_repl_v1(ca_list) } + ReplAttrV1::OauthClaimMap { set } => ValueSetOauthClaimMap::from_repl_v1(set), } } diff --git a/server/lib/src/valueset/oauth.rs b/server/lib/src/valueset/oauth.rs index 47b77cd49..00d276a30 100644 --- a/server/lib/src/valueset/oauth.rs +++ b/server/lib/src/valueset/oauth.rs @@ -1,11 +1,11 @@ use std::collections::btree_map::Entry as BTreeEntry; use std::collections::{BTreeMap, BTreeSet}; -use crate::be::dbvalue::DbValueOauthScopeMapV1; +use crate::be::dbvalue::{DbValueOauthClaimMap, DbValueOauthScopeMapV1}; use crate::prelude::*; -use crate::repl::proto::{ReplAttrV1, ReplOauthScopeMapV1}; +use crate::repl::proto::{ReplAttrV1, ReplOauthClaimMapV1, ReplOauthScopeMapV1}; use crate::schema::SchemaAttribute; -use crate::value::OAUTHSCOPE_RE; +use crate::value::{OauthClaimMapJoin, OAUTHSCOPE_RE}; use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ValueSet}; #[derive(Debug, Clone)] @@ -365,3 +365,368 @@ impl ValueSetT for ValueSetOauthScopeMap { Some(Box::new(self.map.keys().copied())) } } + +#[derive(Debug, Clone, PartialEq)] +pub struct OauthClaimMapping { + join: OauthClaimMapJoin, + values: BTreeMap>, +} + +impl OauthClaimMapping { + pub(crate) fn join(&self) -> OauthClaimMapJoin { + self.join + } + + pub(crate) fn values(&self) -> &BTreeMap> { + &self.values + } +} + +#[derive(Debug, Clone)] +pub struct ValueSetOauthClaimMap { + // Claim Name + map: BTreeMap, +} + +impl ValueSetOauthClaimMap { + pub(crate) fn new(claim: String, join: OauthClaimMapJoin) -> Box { + let mapping = OauthClaimMapping { + join, + values: BTreeMap::default(), + }; + let mut map = BTreeMap::new(); + map.insert(claim, mapping); + Box::new(ValueSetOauthClaimMap { map }) + } + + pub(crate) fn new_value(claim: String, group: Uuid, claims: BTreeSet) -> Box { + let mut values = BTreeMap::default(); + values.insert(group, claims); + + let mapping = OauthClaimMapping { + join: OauthClaimMapJoin::default(), + values, + }; + + let mut map = BTreeMap::new(); + map.insert(claim, mapping); + Box::new(ValueSetOauthClaimMap { map }) + } + + /* + pub(crate) fn push(&mut self, claim: String, mapping: OauthClaimMapping) -> bool { + self.map.insert(claim, mapping).is_none() + } + */ + + pub(crate) fn from_dbvs2(data: Vec) -> Result { + let map = data + .into_iter() + .map(|db_claim_map| match db_claim_map { + DbValueOauthClaimMap::V1 { name, join, values } => ( + name.clone(), + OauthClaimMapping { + join: join.into(), + values: values.clone(), + }, + ), + }) + .collect(); + Ok(Box::new(ValueSetOauthClaimMap { map })) + } + + pub(crate) fn from_repl_v1(data: &[ReplOauthClaimMapV1]) -> Result { + let map = data + .iter() + .map(|ReplOauthClaimMapV1 { name, join, values }| { + ( + name.clone(), + OauthClaimMapping { + join: (*join).into(), + values: values.clone(), + }, + ) + }) + .collect(); + Ok(Box::new(ValueSetOauthClaimMap { map })) + } + + /* + // We need to allow this, because rust doesn't allow us to impl FromIterator on foreign + // types, and tuples are always foreign. + #[allow(clippy::should_implement_trait)] + pub(crate) fn from_iter(iter: T) -> Option> + where + T: IntoIterator, + { + let map = iter.into_iter().collect(); + Some(Box::new(ValueSetOauthClaimMap { map })) + } + */ +} + +impl ValueSetT for ValueSetOauthClaimMap { + fn insert_checked(&mut self, value: Value) -> Result { + match value { + Value::OauthClaimValue(name, uuid, claims) => { + // Add a value to this group associated to this claim. + match self.map.entry(name) { + BTreeEntry::Vacant(e) => { + // New map/value. Use a default joiner. + let mut values = BTreeMap::default(); + values.insert(uuid, claims); + + let claim_map = OauthClaimMapping { + join: OauthClaimMapJoin::default(), + values, + }; + e.insert(claim_map); + Ok(true) + } + BTreeEntry::Occupied(mut e) => { + // Just add the uuid/value, this claim name already exists. + let mapping_mut = e.get_mut(); + match mapping_mut.values.entry(uuid) { + BTreeEntry::Vacant(e) => { + e.insert(claims); + Ok(true) + } + BTreeEntry::Occupied(mut e) => { + e.insert(claims); + Ok(true) + } + } + } + } + } + Value::OauthClaimMap(name, join) => { + match self.map.entry(name) { + BTreeEntry::Vacant(e) => { + // Create a new empty claim mapping. + let claim_map = OauthClaimMapping { + join, + values: BTreeMap::default(), + }; + e.insert(claim_map); + Ok(true) + } + BTreeEntry::Occupied(mut e) => { + // Just update the join strategy. + e.get_mut().join = join; + Ok(true) + } + } + } + _ => Err(OperationError::InvalidValueState), + } + } + + fn clear(&mut self) { + self.map.clear(); + } + + fn remove(&mut self, pv: &PartialValue, _cid: &Cid) -> bool { + let res = match pv { + // Remove this claim as a whole + PartialValue::Iutf8(s) => self.map.remove(s).is_some(), + // Remove all references to this group from this claim map. + PartialValue::Refer(u) => { + let mut contained = false; + for mapping_mut in self.map.values_mut() { + contained |= mapping_mut.values.remove(u).is_some(); + } + contained + } + PartialValue::OauthClaim(s, u) => { + // Remove a uuid from this claim type. + if let Some(mapping_mut) = self.map.get_mut(s) { + mapping_mut.values.remove(u).is_some() + } else { + false + } + } + PartialValue::OauthClaimValue(s, u, v) => { + // Remove a value from this uuid, associated to this claim name. + if let Some(mapping_mut) = self.map.get_mut(s) { + if let Some(claim_mut) = mapping_mut.values.get_mut(u) { + claim_mut.remove(v) + } else { + false + } + } else { + false + } + } + _ => false, + }; + + // Trim anything that is now empty. + self.map + .values_mut() + .for_each(|mapping_mut| mapping_mut.values.retain(|_k, v| !v.is_empty())); + + self.map.retain(|_k, v| !v.values.is_empty()); + + res + } + + fn contains(&self, pv: &PartialValue) -> bool { + match pv { + PartialValue::Iutf8(s) => self.map.contains_key(s), + PartialValue::Refer(u) => { + let mut contained = false; + for mapping in self.map.values() { + contained |= mapping.values.contains_key(u); + } + contained + } + _ => false, + } + } + + fn substring(&self, _pv: &PartialValue) -> bool { + false + } + + fn startswith(&self, _pv: &PartialValue) -> bool { + false + } + + fn endswith(&self, _pv: &PartialValue) -> bool { + false + } + + fn lessthan(&self, _pv: &PartialValue) -> bool { + false + } + + fn len(&self) -> usize { + self.map.len() + } + + fn generate_idx_eq_keys(&self) -> Vec { + self.map + .keys() + .cloned() + .chain( + self.map.values().flat_map(|mapping| { + mapping.values.keys().map(|u| u.as_hyphenated().to_string()) + }), + ) + .collect() + } + + fn syntax(&self) -> SyntaxType { + SyntaxType::OauthClaimMap + } + + fn validate(&self, _schema_attr: &SchemaAttribute) -> bool { + self.map.keys().all(|s| OAUTHSCOPE_RE.is_match(s)) + && self + .map + .values() + .flat_map(|mapping| { + mapping + .values + .values() + .flat_map(|claim_values| claim_values.iter()) + }) + .all(|s| OAUTHSCOPE_RE.is_match(s)) + } + + fn to_proto_string_clone_iter(&self) -> Box + '_> { + Box::new(self.map.iter().flat_map(|(name, mapping)| { + mapping.values.iter().map(move |(group, claims)| { + let join_char = match mapping.join { + OauthClaimMapJoin::CommaSeparatedValue => ',', + OauthClaimMapJoin::SpaceSeparatedValue => ' ', + // Should this be something else? + OauthClaimMapJoin::JsonArray => ';', + }; + + let joined = str_concat!(claims, join_char); + + format!( + "{}: {} \"{:?}\"", + name, + uuid_to_proto_string(*group), + joined + ) + }) + })) + } + + fn to_db_valueset_v2(&self) -> DbValueSetV2 { + DbValueSetV2::OauthClaimMap( + self.map + .iter() + .map(|(name, mapping)| DbValueOauthClaimMap::V1 { + name: name.clone(), + join: mapping.join.into(), + values: mapping.values.clone(), + }) + .collect(), + ) + } + + fn to_repl_v1(&self) -> ReplAttrV1 { + ReplAttrV1::OauthClaimMap { + set: self + .map + .iter() + .map(|(name, mapping)| ReplOauthClaimMapV1 { + name: name.clone(), + join: mapping.join.into(), + values: mapping.values.clone(), + }) + .collect(), + } + } + + fn to_partialvalue_iter(&self) -> Box + '_> { + Box::new(self.map.keys().cloned().map(PartialValue::Iutf8)) + } + + fn to_value_iter(&self) -> Box + '_> { + debug_assert!(false); + Box::new( + std::iter::empty(), /* + self.map + .iter() + .map(|(u, m)| Value::OauthScopeMap(*u, m.clone())), + */ + ) + } + + fn equal(&self, other: &ValueSet) -> bool { + if let Some(other) = other.as_oauthclaim_map() { + &self.map == other + } else { + debug_assert!(false); + false + } + } + + fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> { + if let Some(b) = other.as_oauthclaim_map() { + mergemaps!(self.map, b) + } else { + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + } + + fn as_oauthclaim_map(&self) -> Option<&BTreeMap> { + Some(&self.map) + } + + fn as_ref_uuid_iter(&self) -> Option + '_>> { + // This is what ties us as a type that can be refint checked. + Some(Box::new( + self.map + .values() + .flat_map(|mapping| mapping.values.keys()) + .copied(), + )) + } +} diff --git a/server/testkit/tests/oauth2_test.rs b/server/testkit/tests/oauth2_test.rs index bcd1a29e1..40a90119f 100644 --- a/server/testkit/tests/oauth2_test.rs +++ b/server/testkit/tests/oauth2_test.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use compact_jwt::{JwkKeySet, JwsEs256Verifier, JwsVerifier, OidcToken, OidcUnverified}; use kanidm_proto::constants::uri::{OAUTH2_AUTHORISE, OAUTH2_AUTHORISE_PERMIT}; use kanidm_proto::constants::*; +use kanidm_proto::internal::Oauth2ClaimMapJoin; use kanidm_proto::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, AccessTokenResponse, AuthorisationResponse, GrantTypeReq, OidcDiscoveryResponse, @@ -485,6 +486,27 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { .await .expect("Failed to update oauth2 scopes"); + // Add a custom claim map. + rsclient + .idm_oauth2_rs_update_claim_map( + "test_integration", + "test_claim", + IDM_ALL_ACCOUNTS.name, + &["claim_a".to_string(), "claim_b".to_string()], + ) + .await + .expect("Failed to update oauth2 claims"); + + // Set an alternate join + rsclient + .idm_oauth2_rs_update_claim_map_join( + "test_integration", + "test_claim", + Oauth2ClaimMapJoin::Ssv, + ) + .await + .expect("Failed to update oauth2 claims"); + // Get our admin's auth token for our new client. // We have to re-auth to update the mail field. let res = rsclient @@ -648,6 +670,12 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { assert!(oidc.s_claims.email.as_deref() == Some("oauth_test@localhost")); assert!(oidc.s_claims.email_verified == Some(true)); + eprintln!("{:?}", oidc.claims); + assert_eq!( + oidc.claims.get("test_claim").and_then(|v| v.as_str()), + Some("claim_a claim_b") + ); + // Check the preflight works. let response = client .request( diff --git a/tools/cli/src/cli/common.rs b/tools/cli/src/cli/common.rs index 27f563ba0..a26ddd9fe 100644 --- a/tools/cli/src/cli/common.rs +++ b/tools/cli/src/cli/common.rs @@ -151,7 +151,6 @@ impl CommonOpt { let mut token_refs: Vec<_> = tokens .into_iter() .filter(|(t, _)| t.starts_with(&filter_username)) - .map(|(k, v)| (k, v)) .collect(); match token_refs.len() { diff --git a/tools/cli/src/cli/oauth2.rs b/tools/cli/src/cli/oauth2.rs index bf8d8b70c..5cd01d3c6 100644 --- a/tools/cli/src/cli/oauth2.rs +++ b/tools/cli/src/cli/oauth2.rs @@ -3,6 +3,9 @@ use std::process::exit; use crate::common::OpType; use crate::{handle_client_error, Oauth2Opt, OutputMode}; +use crate::Oauth2ClaimMapJoin; +use kanidm_proto::internal::Oauth2ClaimMapJoin as ProtoOauth2ClaimMapJoin; + impl Oauth2Opt { pub fn debug(&self) -> bool { match self { @@ -25,9 +28,13 @@ impl Oauth2Opt { Oauth2Opt::DisableLegacyCrypto(nopt) => nopt.copt.debug, Oauth2Opt::PreferShortUsername(nopt) => nopt.copt.debug, Oauth2Opt::PreferSPNUsername(nopt) => nopt.copt.debug, - Oauth2Opt::CreateBasic { copt, .. } | Oauth2Opt::CreatePublic { copt, .. } => { - copt.debug - } + Oauth2Opt::CreateBasic { copt, .. } + | Oauth2Opt::CreatePublic { copt, .. } + | Oauth2Opt::UpdateClaimMap { copt, .. } + | Oauth2Opt::UpdateClaimMapJoin { copt, .. } + | Oauth2Opt::DeleteClaimMap { copt, .. } + | Oauth2Opt::EnablePublicLocalhost { copt, .. } + | Oauth2Opt::DisablePublicLocalhost { copt, .. } => copt.debug, Oauth2Opt::SetOrigin { nopt, .. } => nopt.copt.debug, } } @@ -325,6 +332,90 @@ impl Oauth2Opt { Err(e) => handle_client_error(e, nopt.copt.output_mode), } } + Oauth2Opt::UpdateClaimMap { + copt, + name, + group, + claim_name, + values, + } => { + let client = copt.to_client(OpType::Write).await; + match client + .idm_oauth2_rs_update_claim_map( + name.as_str(), + claim_name.as_str(), + group.as_str(), + values, + ) + .await + { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, copt.output_mode), + } + } + Oauth2Opt::UpdateClaimMapJoin { + copt, + name, + claim_name, + join, + } => { + let client = copt.to_client(OpType::Write).await; + + let join = match join { + Oauth2ClaimMapJoin::Csv => ProtoOauth2ClaimMapJoin::Csv, + Oauth2ClaimMapJoin::Ssv => ProtoOauth2ClaimMapJoin::Ssv, + Oauth2ClaimMapJoin::Array => ProtoOauth2ClaimMapJoin::Array, + }; + + match client + .idm_oauth2_rs_update_claim_map_join(name.as_str(), claim_name.as_str(), join) + .await + { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, copt.output_mode), + } + } + Oauth2Opt::DeleteClaimMap { + copt, + name, + claim_name, + group, + } => { + let client = copt.to_client(OpType::Write).await; + match client + .idm_oauth2_rs_delete_claim_map( + name.as_str(), + claim_name.as_str(), + group.as_str(), + ) + .await + { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, copt.output_mode), + } + } + + Oauth2Opt::EnablePublicLocalhost { copt, name } => { + let client = copt.to_client(OpType::Write).await; + match client + .idm_oauth2_rs_enable_public_localhost_redirect(name.as_str()) + .await + { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, copt.output_mode), + } + } + + Oauth2Opt::DisablePublicLocalhost { copt, name } => { + let client = copt.to_client(OpType::Write).await; + match client + .idm_oauth2_rs_disable_public_localhost_redirect(name.as_str()) + .await + { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, copt.output_mode), + } + } } } } diff --git a/tools/cli/src/opt/kanidm.rs b/tools/cli/src/opt/kanidm.rs index 09c34af7c..043a3f2c6 100644 --- a/tools/cli/src/opt/kanidm.rs +++ b/tools/cli/src/opt/kanidm.rs @@ -800,6 +800,38 @@ pub struct Oauth2DeleteScopeMapOpt { group: String, } +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum Oauth2ClaimMapJoin { + Csv, + Ssv, + Array, +} + +impl Oauth2ClaimMapJoin { + pub fn as_str(&self) -> &'static str { + match self { + Self::Csv => "csv", + Self::Ssv => "ssv", + Self::Array => "array", + } + } +} + +impl ValueEnum for Oauth2ClaimMapJoin { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Csv, Self::Ssv, Self::Array] + } + + fn to_possible_value(&self) -> Option { + Some(match self { + Self::Csv => PossibleValue::new("csv"), + Self::Ssv => PossibleValue::new("ssv"), + Self::Array => PossibleValue::new("array"), + }) + } +} + + #[derive(Debug, Subcommand)] pub enum Oauth2Opt { #[clap(name = "list")] @@ -853,6 +885,36 @@ pub enum Oauth2Opt { /// Remove a mapping from groups to scopes DeleteSupScopeMap(Oauth2DeleteScopeMapOpt), + #[clap(name = "update-claim-map", visible_aliases=&["create-claim-map"])] + /// Update or add a new mapping from a group to custom claims that it provides to members + UpdateClaimMap { + #[clap(flatten)] + copt: CommonOpt, + name: String, + claim_name: String, + group: String, + values: Vec, + }, + #[clap(name = "update-claim-map-join")] + UpdateClaimMapJoin { + #[clap(flatten)] + copt: CommonOpt, + name: String, + claim_name: String, + /// The join strategy. Valid values are csv (comma separated value), ssv (space + /// separated value) and array. + join: Oauth2ClaimMapJoin, + }, + #[clap(name = "delete-claim-map")] + /// Remove a mapping from groups to a custom claim + DeleteClaimMap { + #[clap(flatten)] + copt: CommonOpt, + name: String, + claim_name: String, + group: String, + }, + #[clap(name = "reset-secrets")] /// Reset the secrets associated to this resource server ResetSecrets(Named), @@ -900,12 +962,28 @@ pub enum Oauth2Opt { #[clap(name = "disable-legacy-crypto")] DisableLegacyCrypto(Named), #[clap(name = "prefer-short-username")] + + #[clap(name = "enable-localhost-redirects")] + /// Allow public clients to redirect to localhost. + EnablePublicLocalhost { + #[clap(flatten)] + copt: CommonOpt, + name: String, + }, + /// Disable public clients redirecting to localhost. + #[clap(name = "disable-localhost-redirects")] + DisablePublicLocalhost { + #[clap(flatten)] + copt: CommonOpt, + name: String, + }, + /// Use the 'name' attribute instead of 'spn' for the preferred_username PreferShortUsername(Named), #[clap(name = "prefer-spn-username")] /// Use the 'spn' attribute instead of 'name' for the preferred_username PreferSPNUsername(Named), - /// Set the origin of a client + /// Set the origin of an oauth2 client #[clap(name = "set-origin")] SetOrigin { #[clap(flatten)]