mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 12:37:00 +01:00
2390 1980 allow native applications (#2428)
This commit is contained in:
parent
84204ee7ce
commit
8dc884f38e
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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(()));
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
})?;
|
||||
|
|
|
@ -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![
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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()
|
||||
};
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>,
|
||||
},
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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 ...");
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
|
|
Loading…
Reference in a new issue