2390 1980 allow native applications (#2428)

This commit is contained in:
Firstyear 2024-01-16 10:44:12 +10:00 committed by GitHub
parent 84204ee7ce
commit 8dc884f38e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1963 additions and 94 deletions

View file

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

View file

@ -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 <name> <claim_name> <kanidm_group_name> [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 <name> <claim_name> [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 <name> <claim_name> <kanidm_group_name>
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 <name> <displayname> <origin>
kanidm system oauth2 create-public mywebapp "My Web App" https://webapp.example.com
```
To allow localhost redirection
```bash
kanidm system oauth2 enable-localhost-redirects <name>
kanidm system oauth2 disable-localhost-redirects <name>
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 <resource server name>
```
## 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.
<!-- deno-fmt-ignore-start -->
{{#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.
}}
<!-- deno-fmt-ignore-end -->
To create an OAuth2 public resource server:
```bash
kanidm system oauth2 create-public <name> <displayname> <origin>
kanidm system oauth2 create-public mywebapp "My Web App" https://webapp.example.com
```
## Example Integrations
### Apache mod\_auth\_openidc

View file

@ -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<String> = 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
}
}

View file

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

View file

@ -151,25 +151,32 @@ impl FsType {
}
}
impl From<String> 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<Self, Self::Error> {
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(()));
}

View file

@ -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<String>,
filter: Filter<FilterInvalid>,
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<FilterInvalid>,
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<FilterInvalid>,
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,

View file

@ -2856,6 +2856,15 @@ pub(crate) fn route_setup(state: ServerState) -> Router<ServerState> {
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))

View file

@ -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<String>,
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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
Path((rs_name, claim_name, group)): Path<(String, String, String)>,
Json(claims): Json<Vec<String>>,
) -> Result<Json<()>, 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
Path((rs_name, claim_name)): Path<(String, String)>,
Json(join): Json<Oauth2ClaimMapJoin>,
) -> Result<Json<()>, 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
Path((rs_name, claim_name, group)): Path<(String, String, String)>,
) -> Result<Json<()>, 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}",

View file

@ -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<Uuid, BTreeSet<String>>,
},
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DbValueOauthScopeMapV1 {
#[serde(rename = "u")]
@ -672,6 +695,8 @@ pub enum DbValueSetV2 {
OauthScope(Vec<String>),
#[serde(rename = "OM")]
OauthScopeMap(Vec<DbValueOauthScopeMapV1>),
#[serde(rename = "OC")]
OauthClaimMap(Vec<DbValueOauthClaimMap>),
#[serde(rename = "E2")]
PrivateBinary(Vec<Vec<u8>>),
#[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(),

View file

@ -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
})?;

View file

@ -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![

View file

@ -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<String> 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<Attribute> 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,

View file

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

View file

@ -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()
};

View file

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

View file

@ -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<EntryReduced, EntryCommitted>,
qs: &mut TXN,

View file

@ -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<String>,
}
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<Uuid, Vec<(String, ClaimValue)>>,
scope_maps: BTreeMap<Uuid, BTreeSet<String>>,
sup_scope_maps: BTreeMap<Uuid, BTreeSet<String>>,
// 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<Uuid, Vec<(String, ClaimValue)>>,
scopes: &BTreeSet<String>,
) -> BTreeMap<String, serde_json::Value> {
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());
}
}

View file

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

View file

@ -177,7 +177,7 @@ fn enforce_unique<VALID, STATE>(
})?;
// 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;

View file

@ -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<EntryInit, EntryNew> = 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<EntryInit, EntryNew> = 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())
}
);
}
}

View file

@ -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<String>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct ReplOauthClaimMapV1 {
pub name: String,
pub join: DbValueOauthClaimMapJoinV1,
pub values: BTreeMap<Uuid, BTreeSet<String>>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct ReplOauth2SessionV1 {
pub refer: Uuid,
@ -412,6 +420,9 @@ pub enum ReplAttrV1 {
OauthScopeMap {
set: Vec<ReplOauthScopeMapV1>,
},
OauthClaimMap {
set: Vec<ReplOauthClaimMapV1>,
},
Oauth2Session {
set: Vec<ReplOauth2SessionV1>,
},

View file

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

View file

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

View file

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

View file

@ -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<SyntaxType> 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<DbValueOauthClaimMapJoinV1> for OauthClaimMapJoin {
fn from(value: DbValueOauthClaimMapJoinV1) -> OauthClaimMapJoin {
match value {
DbValueOauthClaimMapJoinV1::CommaSeparatedValue => {
OauthClaimMapJoin::CommaSeparatedValue
}
DbValueOauthClaimMapJoinV1::SpaceSeparatedValue => {
OauthClaimMapJoin::SpaceSeparatedValue
}
DbValueOauthClaimMapJoinV1::JsonArray => OauthClaimMapJoin::JsonArray,
}
}
}
impl From<OauthClaimMapJoin> 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<String>),
OauthClaimMap(String, OauthClaimMapJoin),
}
impl PartialEq for Value {
@ -1512,6 +1561,14 @@ impl Value {
}
}
pub fn new_oauthclaimmap(n: String, u: Uuid, c: BTreeSet<String>) -> Option<Self> {
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,

View file

@ -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<String, OauthClaimMapping>> {
debug_assert!(false);
None
}
fn to_value_single(&self) -> Option<Value> {
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<Item = Value>) -> Result<ValueSet
Value::WebauthnAttestationCaList(ca_list) => {
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<ValueSet, OperationErro
DbValueSetV2::WebauthnAttestationCaList { ca_list } => {
ValueSetWebauthnAttestationCaList::from_dbvs2(ca_list)
}
DbValueSetV2::OauthClaimMap(set) => ValueSetOauthClaimMap::from_dbvs2(set),
}
}
@ -849,5 +863,6 @@ pub fn from_repl_v1(rv1: &ReplAttrV1) -> Result<ValueSet, OperationError> {
ReplAttrV1::WebauthnAttestationCaList { ca_list } => {
ValueSetWebauthnAttestationCaList::from_repl_v1(ca_list)
}
ReplAttrV1::OauthClaimMap { set } => ValueSetOauthClaimMap::from_repl_v1(set),
}
}

View file

@ -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<Uuid, BTreeSet<String>>,
}
impl OauthClaimMapping {
pub(crate) fn join(&self) -> OauthClaimMapJoin {
self.join
}
pub(crate) fn values(&self) -> &BTreeMap<Uuid, BTreeSet<String>> {
&self.values
}
}
#[derive(Debug, Clone)]
pub struct ValueSetOauthClaimMap {
// Claim Name
map: BTreeMap<String, OauthClaimMapping>,
}
impl ValueSetOauthClaimMap {
pub(crate) fn new(claim: String, join: OauthClaimMapJoin) -> Box<Self> {
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<String>) -> Box<Self> {
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<DbValueOauthClaimMap>) -> Result<ValueSet, OperationError> {
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<ValueSet, OperationError> {
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<T>(iter: T) -> Option<Box<Self>>
where
T: IntoIterator<Item = (String, OauthClaimMapping)>,
{
let map = iter.into_iter().collect();
Some(Box::new(ValueSetOauthClaimMap { map }))
}
*/
}
impl ValueSetT for ValueSetOauthClaimMap {
fn insert_checked(&mut self, value: Value) -> Result<bool, OperationError> {
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<String> {
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<dyn Iterator<Item = String> + '_> {
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<dyn Iterator<Item = PartialValue> + '_> {
Box::new(self.map.keys().cloned().map(PartialValue::Iutf8))
}
fn to_value_iter(&self) -> Box<dyn Iterator<Item = Value> + '_> {
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<String, OauthClaimMapping>> {
Some(&self.map)
}
fn as_ref_uuid_iter(&self) -> Option<Box<dyn Iterator<Item = Uuid> + '_>> {
// 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(),
))
}
}

View file

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

View file

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

View file

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

View file

@ -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<PossibleValue> {
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<String>,
},
#[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)]