mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 20:47:01 +01:00
613 oauth2 logout (#1184)
* Oauth2 sessions * Start to add session consistency * Add tests for session consistency. * Session refint works! * Add support for oauth2 session removal
This commit is contained in:
parent
69b9c5845a
commit
06c9e087cb
|
@ -1,9 +1,9 @@
|
||||||
# OAuth2
|
# OAuth2
|
||||||
|
|
||||||
OAuth is a web authorisation protocol that allows "single sign on". It's key to note
|
OAuth is a web authorisation protocol that allows "single sign on". It's key to note
|
||||||
OAuth is authorisation, not authentication, as the protocol in its default forms
|
OAuth only provides authorisation, as the protocol in its default forms
|
||||||
do not provide identity or authentication information, only information that
|
do not provide identity or authentication information. All that Oauth2 provides is
|
||||||
an entity is authorised for the requested resources.
|
information that an entity is authorised for the requested resources.
|
||||||
|
|
||||||
OAuth can tie into extensions allowing an identity provider to reveal information
|
OAuth can tie into extensions allowing an identity provider to reveal information
|
||||||
about authorised sessions. This extends OAuth from an authorisation only system
|
about authorised sessions. This extends OAuth from an authorisation only system
|
||||||
|
@ -60,7 +60,8 @@ Kanidm will expose its OAuth2 APIs at the following URLs:
|
||||||
* user auth url: https://idm.example.com/ui/oauth2
|
* user auth url: https://idm.example.com/ui/oauth2
|
||||||
* api auth url: https://idm.example.com/oauth2/authorise
|
* api auth url: https://idm.example.com/oauth2/authorise
|
||||||
* token url: https://idm.example.com/oauth2/token
|
* token url: https://idm.example.com/oauth2/token
|
||||||
* token inspect url: https://idm.example.com/oauth2/inspect
|
* rfc7662 token introspection url: https://idm.example.com/oauth2/token/introspect
|
||||||
|
* rfc7009 token revoke url: https://idm.example.com/oauth2/token/revoke
|
||||||
|
|
||||||
OpenID Connect discovery - you need to substitute your OAuth2 client id in the following
|
OpenID Connect discovery - you need to substitute your OAuth2 client id in the following
|
||||||
urls:
|
urls:
|
||||||
|
@ -87,11 +88,11 @@ The first is scope mappings. These provide a set of scopes if a user is a member
|
||||||
group within Kanidm. This allows you to create a relationship between the scopes of a resource
|
group within Kanidm. This allows you to create a relationship between the scopes of a resource
|
||||||
server, and the groups/roles in Kanidm which can be specific to that resource server.
|
server, and the groups/roles in Kanidm which can be specific to that resource server.
|
||||||
|
|
||||||
For an authorisation to proceed, all scopes requested must be available in the final scope set
|
For an authorisation to proceed, all scopes requested by the resource server must be available in the
|
||||||
that is granted to the account.
|
final scope set that is granted to the account.
|
||||||
|
|
||||||
The second is supplemental scope mappings. These function the same as scope maps where membership
|
The second is supplemental scope mappings. These function the same as scope maps where membership
|
||||||
of a group provides a set of scopes to the account, however these scopes are NOT consulted during
|
of a group provides a set of scopes to the account. However these scopes are NOT consulted during
|
||||||
authorisation decisions made by Kanidm. These scopes exists to allow optional properties to be
|
authorisation decisions made by Kanidm. These scopes exists to allow optional properties to be
|
||||||
provided (such as personal information about a subset of accounts to be revealed) or so that the resource server
|
provided (such as personal information about a subset of accounts to be revealed) or so that the resource server
|
||||||
may make it's own authorisation decisions based on the provided scopes.
|
may make it's own authorisation decisions based on the provided scopes.
|
||||||
|
@ -184,6 +185,13 @@ Not all resource servers support modern standards like PKCE or ECDSA. In these s
|
||||||
it may be necessary to disable these on a per-resource server basis. Disabling these on
|
it may be necessary to disable these on a per-resource server basis. Disabling these on
|
||||||
one resource server will not affect others.
|
one resource server will not affect others.
|
||||||
|
|
||||||
|
{{#template
|
||||||
|
templates/kani-warning.md
|
||||||
|
imagepath=images
|
||||||
|
title=WARNING
|
||||||
|
text=Changing these settings MAY have serious consequences on the security of your resource server. You should avoid changing these if at all possible!
|
||||||
|
}}
|
||||||
|
|
||||||
To disable PKCE for a resource server:
|
To disable PKCE for a resource server:
|
||||||
|
|
||||||
kanidm system oauth2 warning_insecure_client_disable_pkce <resource server name>
|
kanidm system oauth2 warning_insecure_client_disable_pkce <resource server name>
|
||||||
|
|
|
@ -94,11 +94,16 @@ pub struct AccessTokenRequest {
|
||||||
pub code_verifier: Option<String>,
|
pub code_verifier: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// We now check code_verifier is the same via the formula.
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct TokenRevokeRequest {
|
||||||
|
pub token: String,
|
||||||
|
/// Generally not needed. See:
|
||||||
|
/// <https://datatracker.ietf.org/doc/html/rfc7009#section-4.1.2>
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token_type_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// If and only if it checks out, we proceed.
|
// The corresponding Response to a revoke request is empty body with 200.
|
||||||
|
|
||||||
// Returned as a json body
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct AccessTokenResponse {
|
pub struct AccessTokenResponse {
|
||||||
|
|
|
@ -24,6 +24,7 @@ use kanidmd_lib::{
|
||||||
},
|
},
|
||||||
idm::delayed::DelayedAction,
|
idm::delayed::DelayedAction,
|
||||||
idm::event::{GeneratePasswordEvent, RegenerateRadiusSecretEvent, UnixPasswordChangeEvent},
|
idm::event::{GeneratePasswordEvent, RegenerateRadiusSecretEvent, UnixPasswordChangeEvent},
|
||||||
|
idm::oauth2::{Oauth2Error, TokenRevokeRequest},
|
||||||
idm::server::{IdmServer, IdmServerTransaction},
|
idm::server::{IdmServer, IdmServerTransaction},
|
||||||
idm::serviceaccount::{DestroyApiTokenEvent, GenerateApiTokenEvent},
|
idm::serviceaccount::{DestroyApiTokenEvent, GenerateApiTokenEvent},
|
||||||
modify::{Modify, ModifyInvalid, ModifyList},
|
modify::{Modify, ModifyInvalid, ModifyList},
|
||||||
|
@ -1351,8 +1352,8 @@ impl QueryServerWriteV1 {
|
||||||
filter: Filter<FilterInvalid>,
|
filter: Filter<FilterInvalid>,
|
||||||
eventid: Uuid,
|
eventid: Uuid,
|
||||||
) -> Result<(), OperationError> {
|
) -> Result<(), OperationError> {
|
||||||
let mut idms_prox_write = self.idms.proxy_write(duration_from_epoch_now()).await;
|
|
||||||
let ct = duration_from_epoch_now();
|
let ct = duration_from_epoch_now();
|
||||||
|
let mut idms_prox_write = self.idms.proxy_write(ct).await;
|
||||||
|
|
||||||
let ident = idms_prox_write
|
let ident = idms_prox_write
|
||||||
.validate_and_parse_token_to_ident(uat.as_deref(), ct)
|
.validate_and_parse_token_to_ident(uat.as_deref(), ct)
|
||||||
|
@ -1392,6 +1393,24 @@ impl QueryServerWriteV1 {
|
||||||
.and_then(|_| idms_prox_write.commit().map(|_| ()))
|
.and_then(|_| idms_prox_write.commit().map(|_| ()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(
|
||||||
|
level = "info",
|
||||||
|
skip_all,
|
||||||
|
fields(uuid = ?eventid)
|
||||||
|
)]
|
||||||
|
pub async fn handle_oauth2_token_revoke(
|
||||||
|
&self,
|
||||||
|
client_authz: String,
|
||||||
|
intr_req: TokenRevokeRequest,
|
||||||
|
eventid: Uuid,
|
||||||
|
) -> Result<(), Oauth2Error> {
|
||||||
|
let ct = duration_from_epoch_now();
|
||||||
|
let mut idms_prox_write = self.idms.proxy_write(ct).await;
|
||||||
|
idms_prox_write
|
||||||
|
.oauth2_token_revoke(&client_authz, &intr_req, ct)
|
||||||
|
.and_then(|()| idms_prox_write.commit().map_err(Oauth2Error::ServerError))
|
||||||
|
}
|
||||||
|
|
||||||
// ===== These below are internal only event types. =====
|
// ===== These below are internal only event types. =====
|
||||||
#[instrument(
|
#[instrument(
|
||||||
level = "info",
|
level = "info",
|
||||||
|
|
|
@ -2,7 +2,7 @@ use kanidm_proto::oauth2::AuthorisationResponse;
|
||||||
use kanidm_proto::v1::Entry as ProtoEntry;
|
use kanidm_proto::v1::Entry as ProtoEntry;
|
||||||
use kanidmd_lib::idm::oauth2::{
|
use kanidmd_lib::idm::oauth2::{
|
||||||
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
|
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
|
||||||
AuthoriseResponse, ErrorResponse, Oauth2Error,
|
AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest,
|
||||||
};
|
};
|
||||||
use kanidmd_lib::prelude::*;
|
use kanidmd_lib::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
@ -723,6 +723,68 @@ pub async fn oauth2_token_introspect_post(mut req: tide::Request<AppState>) -> t
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn oauth2_token_revoke_post(mut req: tide::Request<AppState>) -> tide::Result {
|
||||||
|
// This is called directly by the resource server, where we then revoke
|
||||||
|
// the token identified by this request.
|
||||||
|
let (eventid, hvalue) = req.new_eventid();
|
||||||
|
|
||||||
|
let client_authz = req
|
||||||
|
.header("authorization")
|
||||||
|
.and_then(|hv| hv.get(0))
|
||||||
|
.and_then(|h| h.as_str().strip_prefix("Basic "))
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
error!("Basic Authentication Not Provided");
|
||||||
|
tide::Error::from_str(
|
||||||
|
tide::StatusCode::Unauthorized,
|
||||||
|
"Invalid Basic Authorisation",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Get the introspection request, could we accept json or form? Prob needs content type here.
|
||||||
|
let intr_req: TokenRevokeRequest = req.body_form().await.map_err(|e| {
|
||||||
|
request_error!("{:?}", e);
|
||||||
|
tide::Error::from_str(
|
||||||
|
tide::StatusCode::BadRequest,
|
||||||
|
"Invalid Oauth2 TokenRevokeRequest",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
request_trace!("Revoke Request - {:?}", intr_req);
|
||||||
|
|
||||||
|
let res = req
|
||||||
|
.state()
|
||||||
|
.qe_w_ref
|
||||||
|
.handle_oauth2_token_revoke(client_authz, intr_req, eventid)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(()) => Ok(tide::Response::new(200)),
|
||||||
|
Err(Oauth2Error::AuthenticationRequired) => {
|
||||||
|
// This will trigger our ui to auth and retry.
|
||||||
|
Ok(tide::Response::new(tide::StatusCode::Unauthorized))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
||||||
|
let err = ErrorResponse {
|
||||||
|
error: e.to_string(),
|
||||||
|
error_description: None,
|
||||||
|
error_uri: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut res = tide::Response::new(400);
|
||||||
|
tide::Body::from_json(&err).map(|b| {
|
||||||
|
res.set_body(b);
|
||||||
|
res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(|mut res| {
|
||||||
|
res.insert_header("X-KANIDM-OPID", hvalue);
|
||||||
|
res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn oauth2_route_setup(appserver: &mut tide::Route<'_, AppState>, routemap: &mut RouteMap) {
|
pub fn oauth2_route_setup(appserver: &mut tide::Route<'_, AppState>, routemap: &mut RouteMap) {
|
||||||
let mut oauth2_process = appserver.at("/oauth2");
|
let mut oauth2_process = appserver.at("/oauth2");
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
|
@ -738,36 +800,45 @@ pub fn oauth2_route_setup(appserver: &mut tide::Route<'_, AppState>, routemap: &
|
||||||
.at("/authorise/permit")
|
.at("/authorise/permit")
|
||||||
.mapped_post(routemap, oauth2_authorise_permit_post)
|
.mapped_post(routemap, oauth2_authorise_permit_post)
|
||||||
.mapped_get(routemap, oauth2_authorise_permit_get);
|
.mapped_get(routemap, oauth2_authorise_permit_get);
|
||||||
|
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
oauth2_process
|
oauth2_process
|
||||||
.at("/authorise/reject")
|
.at("/authorise/reject")
|
||||||
.mapped_post(routemap, oauth2_authorise_reject_post)
|
.mapped_post(routemap, oauth2_authorise_reject_post)
|
||||||
.mapped_get(routemap, oauth2_authorise_reject_get);
|
.mapped_get(routemap, oauth2_authorise_reject_get);
|
||||||
|
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
oauth2_process
|
oauth2_process
|
||||||
.at("/token")
|
.at("/token")
|
||||||
.mapped_post(routemap, oauth2_token_post);
|
.mapped_post(routemap, oauth2_token_post);
|
||||||
|
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
oauth2_process
|
oauth2_process
|
||||||
.at("/token/introspect")
|
.at("/token/introspect")
|
||||||
.mapped_post(routemap, oauth2_token_introspect_post);
|
.mapped_post(routemap, oauth2_token_introspect_post);
|
||||||
|
oauth2_process
|
||||||
|
.at("/token/revoke")
|
||||||
|
.mapped_post(routemap, oauth2_token_revoke_post);
|
||||||
|
|
||||||
let mut openid_process = appserver.at("/oauth2/openid");
|
let mut openid_process = appserver.at("/oauth2/openid");
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
|
|
||||||
openid_process
|
openid_process
|
||||||
.at("/:client_id/.well-known/openid-configuration")
|
.at("/:client_id/.well-known/openid-configuration")
|
||||||
.mapped_get(routemap, oauth2_openid_discovery_get);
|
.mapped_get(routemap, oauth2_openid_discovery_get);
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
|
|
||||||
openid_process
|
openid_process
|
||||||
.at("/:client_id/userinfo")
|
.at("/:client_id/userinfo")
|
||||||
.mapped_get(routemap, oauth2_openid_userinfo_get);
|
.mapped_get(routemap, oauth2_openid_userinfo_get);
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
|
|
||||||
openid_process
|
openid_process
|
||||||
.at("/:client_id/public_key.jwk")
|
.at("/:client_id/public_key.jwk")
|
||||||
.mapped_get(routemap, oauth2_openid_publickey_get);
|
.mapped_get(routemap, oauth2_openid_publickey_get);
|
||||||
|
|
|
@ -399,6 +399,22 @@ pub enum DbValueSession {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub enum DbValueOauth2Session {
|
||||||
|
V1 {
|
||||||
|
#[serde(rename = "u")]
|
||||||
|
refer: Uuid,
|
||||||
|
#[serde(rename = "p")]
|
||||||
|
parent: Uuid,
|
||||||
|
#[serde(rename = "e")]
|
||||||
|
expiry: Option<String>,
|
||||||
|
#[serde(rename = "i")]
|
||||||
|
issued_at: String,
|
||||||
|
#[serde(rename = "r")]
|
||||||
|
rs_uuid: Uuid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum DbValueV1 {
|
pub enum DbValueV1 {
|
||||||
#[serde(rename = "U8")]
|
#[serde(rename = "U8")]
|
||||||
|
@ -532,6 +548,8 @@ pub enum DbValueSetV2 {
|
||||||
JwsKeyEs256(Vec<Vec<u8>>),
|
JwsKeyEs256(Vec<Vec<u8>>),
|
||||||
#[serde(rename = "JR")]
|
#[serde(rename = "JR")]
|
||||||
JwsKeyRs256(Vec<Vec<u8>>),
|
JwsKeyRs256(Vec<Vec<u8>>),
|
||||||
|
#[serde(rename = "AS")]
|
||||||
|
Oauth2Session(Vec<DbValueOauth2Session>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DbValueSetV2 {
|
impl DbValueSetV2 {
|
||||||
|
@ -568,6 +586,7 @@ impl DbValueSetV2 {
|
||||||
DbValueSetV2::DeviceKey(set) => set.len(),
|
DbValueSetV2::DeviceKey(set) => set.len(),
|
||||||
DbValueSetV2::TrustedDeviceEnrollment(set) => set.len(),
|
DbValueSetV2::TrustedDeviceEnrollment(set) => set.len(),
|
||||||
DbValueSetV2::Session(set) => set.len(),
|
DbValueSetV2::Session(set) => set.len(),
|
||||||
|
DbValueSetV2::Oauth2Session(set) => set.len(),
|
||||||
DbValueSetV2::JwsKeyEs256(set) => set.len(),
|
DbValueSetV2::JwsKeyEs256(set) => set.len(),
|
||||||
DbValueSetV2::JwsKeyRs256(set) => set.len(),
|
DbValueSetV2::JwsKeyRs256(set) => set.len(),
|
||||||
}
|
}
|
||||||
|
|
|
@ -1171,6 +1171,37 @@ pub const JSON_SCHEMA_ATTR_USER_AUTH_TOKEN_SESSION: &str = r#"{
|
||||||
}
|
}
|
||||||
}"#;
|
}"#;
|
||||||
|
|
||||||
|
pub const JSON_SCHEMA_ATTR_OAUTH2_SESSION: &str = r#"{
|
||||||
|
"attrs": {
|
||||||
|
"class": [
|
||||||
|
"object",
|
||||||
|
"system",
|
||||||
|
"attributetype"
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
"A session entry to an active oauth2 session, bound to a parent user auth token"
|
||||||
|
],
|
||||||
|
"index": [
|
||||||
|
"EQUALITY"
|
||||||
|
],
|
||||||
|
"unique": [
|
||||||
|
"false"
|
||||||
|
],
|
||||||
|
"multivalue": [
|
||||||
|
"true"
|
||||||
|
],
|
||||||
|
"attributename": [
|
||||||
|
"oauth2_session"
|
||||||
|
],
|
||||||
|
"syntax": [
|
||||||
|
"OAUTH2SESSION"
|
||||||
|
],
|
||||||
|
"uuid": [
|
||||||
|
"00000000-0000-0000-0000-ffff00000117"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
pub const JSON_SCHEMA_ATTR_SYNC_TOKEN_SESSION: &str = r#"{
|
pub const JSON_SCHEMA_ATTR_SYNC_TOKEN_SESSION: &str = r#"{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"class": [
|
"class": [
|
||||||
|
@ -1371,7 +1402,8 @@ pub const JSON_SCHEMA_CLASS_ACCOUNT: &str = r#"
|
||||||
"account_valid_from",
|
"account_valid_from",
|
||||||
"mail",
|
"mail",
|
||||||
"oauth2_consent_scope_map",
|
"oauth2_consent_scope_map",
|
||||||
"user_auth_token_session"
|
"user_auth_token_session",
|
||||||
|
"oauth2_session"
|
||||||
],
|
],
|
||||||
"systemmust": [
|
"systemmust": [
|
||||||
"displayname",
|
"displayname",
|
||||||
|
|
|
@ -201,6 +201,7 @@ pub const _UUID_SCHEMA_CLASS_SYNC_ACCOUNT: Uuid = uuid!("00000000-0000-0000-0000
|
||||||
pub const _UUID_SCHEMA_ATTR_SYNC_TOKEN_SESSION: Uuid =
|
pub const _UUID_SCHEMA_ATTR_SYNC_TOKEN_SESSION: Uuid =
|
||||||
uuid!("00000000-0000-0000-0000-ffff00000115");
|
uuid!("00000000-0000-0000-0000-ffff00000115");
|
||||||
pub const _UUID_SCHEMA_ATTR_SYNC_COOKIE: Uuid = uuid!("00000000-0000-0000-0000-ffff00000116");
|
pub const _UUID_SCHEMA_ATTR_SYNC_COOKIE: Uuid = uuid!("00000000-0000-0000-0000-ffff00000116");
|
||||||
|
pub const _UUID_SCHEMA_ATTR_OAUTH2_SESSION: Uuid = uuid!("00000000-0000-0000-0000-ffff00000117");
|
||||||
|
|
||||||
// System and domain infos
|
// System and domain infos
|
||||||
// I'd like to strongly criticise william of the past for making poor choices about these allocations.
|
// I'd like to strongly criticise william of the past for making poor choices about these allocations.
|
||||||
|
|
|
@ -52,7 +52,9 @@ use crate::prelude::*;
|
||||||
use crate::repl::cid::Cid;
|
use crate::repl::cid::Cid;
|
||||||
use crate::repl::entry::EntryChangelog;
|
use crate::repl::entry::EntryChangelog;
|
||||||
use crate::schema::{SchemaAttribute, SchemaClass, SchemaTransaction};
|
use crate::schema::{SchemaAttribute, SchemaClass, SchemaTransaction};
|
||||||
use crate::value::{IndexType, IntentTokenState, PartialValue, Session, SyntaxType, Value};
|
use crate::value::{
|
||||||
|
IndexType, IntentTokenState, Oauth2Session, PartialValue, Session, SyntaxType, Value,
|
||||||
|
};
|
||||||
use crate::valueset::{self, ValueSet};
|
use crate::valueset::{self, ValueSet};
|
||||||
|
|
||||||
// use std::convert::TryFrom;
|
// use std::convert::TryFrom;
|
||||||
|
@ -1863,6 +1865,16 @@ impl<VALID, STATE> Entry<VALID, STATE> {
|
||||||
self.attrs.get(attr).and_then(|vs| vs.as_session_map())
|
self.attrs.get(attr).and_then(|vs| vs.as_session_map())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn get_ava_as_oauth2session_map(
|
||||||
|
&self,
|
||||||
|
attr: &str,
|
||||||
|
) -> Option<&std::collections::BTreeMap<Uuid, Oauth2Session>> {
|
||||||
|
self.attrs
|
||||||
|
.get(attr)
|
||||||
|
.and_then(|vs| vs.as_oauth2session_map())
|
||||||
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
/// If possible, return an iterator over the set of values transformed into a `&str`.
|
/// If possible, return an iterator over the set of values transformed into a `&str`.
|
||||||
pub fn get_ava_iter_iname(&self, attr: &str) -> Option<impl Iterator<Item = &str>> {
|
pub fn get_ava_iter_iname(&self, attr: &str) -> Option<impl Iterator<Item = &str>> {
|
||||||
|
|
|
@ -13,6 +13,7 @@ pub enum DelayedAction {
|
||||||
BackupCodeRemoval(BackupCodeRemoval),
|
BackupCodeRemoval(BackupCodeRemoval),
|
||||||
Oauth2ConsentGrant(Oauth2ConsentGrant),
|
Oauth2ConsentGrant(Oauth2ConsentGrant),
|
||||||
AuthSessionRecord(AuthSessionRecord),
|
AuthSessionRecord(AuthSessionRecord),
|
||||||
|
Oauth2SessionRecord(Oauth2SessionRecord),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PasswordUpgrade {
|
pub struct PasswordUpgrade {
|
||||||
|
@ -70,3 +71,14 @@ pub struct AuthSessionRecord {
|
||||||
pub issued_by: IdentityId,
|
pub issued_by: IdentityId,
|
||||||
pub scope: AccessScope,
|
pub scope: AccessScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Oauth2SessionRecord {
|
||||||
|
pub target_uuid: Uuid,
|
||||||
|
pub parent_session_id: Uuid,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub expiry: Option<OffsetDateTime>,
|
||||||
|
pub issued_at: OffsetDateTime,
|
||||||
|
// Which rs is this related to?
|
||||||
|
pub rs_uuid: Uuid,
|
||||||
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ use hashbrown::HashMap;
|
||||||
pub use kanidm_proto::oauth2::{
|
pub use kanidm_proto::oauth2::{
|
||||||
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
||||||
AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse,
|
AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse,
|
||||||
OidcDiscoveryResponse,
|
OidcDiscoveryResponse, TokenRevokeRequest,
|
||||||
};
|
};
|
||||||
use kanidm_proto::oauth2::{
|
use kanidm_proto::oauth2::{
|
||||||
ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode, ResponseType, SubjectType,
|
ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode, ResponseType, SubjectType,
|
||||||
|
@ -34,8 +34,10 @@ use tracing::trace;
|
||||||
use url::{Origin, Url};
|
use url::{Origin, Url};
|
||||||
|
|
||||||
use crate::identity::IdentityId;
|
use crate::identity::IdentityId;
|
||||||
use crate::idm::delayed::{DelayedAction, Oauth2ConsentGrant};
|
use crate::idm::delayed::{DelayedAction, Oauth2ConsentGrant, Oauth2SessionRecord};
|
||||||
use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerTransaction};
|
use crate::idm::server::{
|
||||||
|
IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction, IdmServerTransaction,
|
||||||
|
};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::value::OAUTHSCOPE_RE;
|
use crate::value::OAUTHSCOPE_RE;
|
||||||
|
|
||||||
|
@ -57,6 +59,8 @@ pub enum Oauth2Error {
|
||||||
// from https://datatracker.ietf.org/doc/html/rfc6750
|
// from https://datatracker.ietf.org/doc/html/rfc6750
|
||||||
InvalidToken,
|
InvalidToken,
|
||||||
InsufficientScope,
|
InsufficientScope,
|
||||||
|
// from https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1
|
||||||
|
UnsupportedTokenType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for Oauth2Error {
|
impl std::fmt::Display for Oauth2Error {
|
||||||
|
@ -74,6 +78,7 @@ impl std::fmt::Display for Oauth2Error {
|
||||||
Oauth2Error::TemporarilyUnavailable => "temporarily_unavailable",
|
Oauth2Error::TemporarilyUnavailable => "temporarily_unavailable",
|
||||||
Oauth2Error::InvalidToken => "invalid_token",
|
Oauth2Error::InvalidToken => "invalid_token",
|
||||||
Oauth2Error::InsufficientScope => "insufficient_scope",
|
Oauth2Error::InsufficientScope => "insufficient_scope",
|
||||||
|
Oauth2Error::UnsupportedTokenType => "unsupported_token_type",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -116,31 +121,38 @@ struct TokenExchangeCode {
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
enum Oauth2TokenType {
|
enum Oauth2TokenType {
|
||||||
Access(Oauth2AccessToken),
|
Access {
|
||||||
Refresh,
|
scopes: Vec<String>,
|
||||||
|
parent_session_id: Uuid,
|
||||||
|
session_id: Uuid,
|
||||||
|
auth_type: AuthType,
|
||||||
|
expiry: time::OffsetDateTime,
|
||||||
|
uuid: Uuid,
|
||||||
|
iat: i64,
|
||||||
|
nbf: i64,
|
||||||
|
auth_time: Option<i64>,
|
||||||
|
},
|
||||||
|
Refresh {
|
||||||
|
parent_session_id: Uuid,
|
||||||
|
session_id: Uuid,
|
||||||
|
expiry: time::OffsetDateTime,
|
||||||
|
uuid: Uuid,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Oauth2TokenType {
|
impl fmt::Display for Oauth2TokenType {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Oauth2TokenType::Access(_) => write!(f, "access_token"),
|
Oauth2TokenType::Access { session_id, .. } => {
|
||||||
Oauth2TokenType::Refresh => write!(f, "refresh_token"),
|
write!(f, "access_token ({}) ", session_id)
|
||||||
|
}
|
||||||
|
Oauth2TokenType::Refresh { session_id, .. } => {
|
||||||
|
write!(f, "refresh_token ({}) ", session_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
|
||||||
struct Oauth2AccessToken {
|
|
||||||
pub scopes: Vec<String>,
|
|
||||||
pub session_id: Uuid,
|
|
||||||
pub auth_type: AuthType,
|
|
||||||
pub expiry: time::OffsetDateTime,
|
|
||||||
pub uuid: Uuid,
|
|
||||||
pub iat: i64,
|
|
||||||
pub nbf: i64,
|
|
||||||
pub auth_time: Option<i64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AuthoriseResponse {
|
pub enum AuthoriseResponse {
|
||||||
ConsentRequested {
|
ConsentRequested {
|
||||||
|
@ -434,6 +446,92 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
|
pub fn oauth2_token_revoke(
|
||||||
|
&mut self,
|
||||||
|
client_authz: &str,
|
||||||
|
revoke_req: &TokenRevokeRequest,
|
||||||
|
ct: Duration,
|
||||||
|
) -> Result<(), Oauth2Error> {
|
||||||
|
let (client_id, secret) = parse_basic_authz(client_authz)?;
|
||||||
|
|
||||||
|
// Get the o2rs for the handle.
|
||||||
|
let o2rs = self.oauth2rs.inner.rs_set.get(&client_id).ok_or_else(|| {
|
||||||
|
admin_warn!("Invalid oauth2 client_id");
|
||||||
|
Oauth2Error::AuthenticationRequired
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// check the secret.
|
||||||
|
if o2rs.authz_secret != secret {
|
||||||
|
security_info!("Invalid oauth2 client_id secret");
|
||||||
|
return Err(Oauth2Error::AuthenticationRequired);
|
||||||
|
}
|
||||||
|
// We are authenticated! Yay! Now we can actually check things ...
|
||||||
|
|
||||||
|
// Can we deserialise the token?
|
||||||
|
let token: Oauth2TokenType = o2rs
|
||||||
|
.token_fernet
|
||||||
|
.decrypt(&revoke_req.token)
|
||||||
|
.map_err(|_| {
|
||||||
|
admin_error!("Failed to decrypt token introspection request");
|
||||||
|
Oauth2Error::InvalidRequest
|
||||||
|
})
|
||||||
|
.and_then(|data| {
|
||||||
|
serde_json::from_slice(&data).map_err(|e| {
|
||||||
|
admin_error!("Failed to deserialise token - {:?}", e);
|
||||||
|
Oauth2Error::InvalidRequest
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// From these tokens, what we need is the identifiers that *might* exist,
|
||||||
|
// such that we can remove them.
|
||||||
|
match token {
|
||||||
|
Oauth2TokenType::Access {
|
||||||
|
session_id,
|
||||||
|
expiry,
|
||||||
|
uuid,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| Oauth2TokenType::Refresh {
|
||||||
|
session_id,
|
||||||
|
expiry,
|
||||||
|
uuid,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
// Only submit a revocation if the token is not yet expired.
|
||||||
|
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
||||||
|
if expiry <= odt_ct {
|
||||||
|
security_info!(?uuid, "access token has expired, returning inactive");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consider replication. We have servers A and B. A issues our oauth2
|
||||||
|
// token to the client. The resource server then issues the revoke request
|
||||||
|
// to B. In this case A has not yet replicated the session to B, but we
|
||||||
|
// still need to ensure the revoke is respected. As a result, we don't
|
||||||
|
// actually consult if the session is present on the account, we simply
|
||||||
|
// submit the Modify::Remove. This way it's inserted into the entry changelog
|
||||||
|
// and when replication converges the session is actually removed.
|
||||||
|
|
||||||
|
let modlist = ModifyList::new_list(vec![Modify::Removed(
|
||||||
|
AttrString::from("oauth2_session"),
|
||||||
|
PartialValue::Refer(session_id),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
self.qs_write
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(uuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
admin_error!("Failed to modify - revoke oauth2 session {:?}", e);
|
||||||
|
Oauth2Error::ServerError(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Oauth2ResourceServersReadTransaction {
|
impl Oauth2ResourceServersReadTransaction {
|
||||||
pub fn check_oauth2_authorisation(
|
pub fn check_oauth2_authorisation(
|
||||||
&self,
|
&self,
|
||||||
|
@ -853,15 +951,8 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
client_authz: Option<&str>,
|
client_authz: Option<&str>,
|
||||||
token_req: &AccessTokenRequest,
|
token_req: &AccessTokenRequest,
|
||||||
ct: Duration,
|
ct: Duration,
|
||||||
|
async_tx: &Sender<DelayedAction>,
|
||||||
) -> Result<AccessTokenResponse, Oauth2Error> {
|
) -> Result<AccessTokenResponse, Oauth2Error> {
|
||||||
// TODO: add refresh token grant type.
|
|
||||||
// If it's a refresh token grant, are the consent permissions the same?
|
|
||||||
|
|
||||||
if token_req.grant_type != "authorization_code" {
|
|
||||||
admin_warn!("Invalid oauth2 grant_type (should be 'authorization_code')");
|
|
||||||
return Err(Oauth2Error::InvalidRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (client_id, secret) = if let Some(client_authz) = client_authz {
|
let (client_id, secret) = if let Some(client_authz) = client_authz {
|
||||||
parse_basic_authz(client_authz)?
|
parse_basic_authz(client_authz)?
|
||||||
} else {
|
} else {
|
||||||
|
@ -887,8 +978,26 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
security_info!("Invalid oauth2 client_id secret");
|
security_info!("Invalid oauth2 client_id secret");
|
||||||
return Err(Oauth2Error::AuthenticationRequired);
|
return Err(Oauth2Error::AuthenticationRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We are authenticated! Yay! Now we can actually check things ...
|
// We are authenticated! Yay! Now we can actually check things ...
|
||||||
|
|
||||||
|
// TODO: add refresh token grant type.
|
||||||
|
// If it's a refresh token grant, are the consent permissions the same?
|
||||||
|
if token_req.grant_type == "authorization_code" {
|
||||||
|
self.check_oauth2_token_exchange_authorization_code(o2rs, token_req, ct, async_tx)
|
||||||
|
} else {
|
||||||
|
admin_warn!("Invalid oauth2 grant_type (should be 'authorization_code')");
|
||||||
|
return Err(Oauth2Error::InvalidRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_oauth2_token_exchange_authorization_code(
|
||||||
|
&self,
|
||||||
|
o2rs: &Oauth2RS,
|
||||||
|
token_req: &AccessTokenRequest,
|
||||||
|
ct: Duration,
|
||||||
|
async_tx: &Sender<DelayedAction>,
|
||||||
|
) -> Result<AccessTokenResponse, Oauth2Error> {
|
||||||
// Check the token_req is within the valid time, and correctly signed for
|
// Check the token_req is within the valid time, and correctly signed for
|
||||||
// this client.
|
// this client.
|
||||||
|
|
||||||
|
@ -1023,7 +1132,7 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
let oidc = OidcToken {
|
let oidc = OidcToken {
|
||||||
iss,
|
iss,
|
||||||
sub: OidcSubject::U(code_xchg.uat.uuid),
|
sub: OidcSubject::U(code_xchg.uat.uuid),
|
||||||
aud: client_id.clone(),
|
aud: o2rs.name.clone(),
|
||||||
iat,
|
iat,
|
||||||
nbf: Some(iat),
|
nbf: Some(iat),
|
||||||
exp,
|
exp,
|
||||||
|
@ -1032,7 +1141,7 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
at_hash: None,
|
at_hash: None,
|
||||||
acr: None,
|
acr: None,
|
||||||
amr,
|
amr,
|
||||||
azp: Some(client_id.clone()),
|
azp: Some(o2rs.name.clone()),
|
||||||
jti: None,
|
jti: None,
|
||||||
s_claims: OidcClaims {
|
s_claims: OidcClaims {
|
||||||
// Map from displayname
|
// Map from displayname
|
||||||
|
@ -1061,17 +1170,22 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Refresh tokens!
|
let session_id = Uuid::new_v4();
|
||||||
let access_token_raw = Oauth2TokenType::Access(Oauth2AccessToken {
|
let parent_session_id = code_xchg.uat.session_id;
|
||||||
|
|
||||||
|
// We need to record this into the record? Delayed action?
|
||||||
|
|
||||||
|
let access_token_raw = Oauth2TokenType::Access {
|
||||||
scopes: code_xchg.scopes,
|
scopes: code_xchg.scopes,
|
||||||
session_id: code_xchg.uat.session_id,
|
parent_session_id,
|
||||||
|
session_id,
|
||||||
auth_type: code_xchg.uat.auth_type,
|
auth_type: code_xchg.uat.auth_type,
|
||||||
expiry,
|
expiry,
|
||||||
uuid: code_xchg.uat.uuid,
|
uuid: code_xchg.uat.uuid,
|
||||||
iat,
|
iat,
|
||||||
nbf: iat,
|
nbf: iat,
|
||||||
auth_time: None,
|
auth_time: None,
|
||||||
});
|
};
|
||||||
|
|
||||||
let access_token_data = serde_json::to_vec(&access_token_raw).map_err(|e| {
|
let access_token_data = serde_json::to_vec(&access_token_raw).map_err(|e| {
|
||||||
admin_error!(err = ?e, "Unable to encode consent data");
|
admin_error!(err = ?e, "Unable to encode consent data");
|
||||||
|
@ -1084,6 +1198,20 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
|
|
||||||
let refresh_token = None;
|
let refresh_token = None;
|
||||||
|
|
||||||
|
async_tx
|
||||||
|
.send(DelayedAction::Oauth2SessionRecord(Oauth2SessionRecord {
|
||||||
|
target_uuid: code_xchg.uat.uuid,
|
||||||
|
parent_session_id,
|
||||||
|
session_id,
|
||||||
|
expiry: Some(expiry),
|
||||||
|
issued_at: odt_ct,
|
||||||
|
rs_uuid: o2rs.uuid,
|
||||||
|
}))
|
||||||
|
.map_err(|e| {
|
||||||
|
admin_error!(err = ?e, "Unable to submit oauth2 session record");
|
||||||
|
Oauth2Error::ServerError(OperationError::InvalidState)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(AccessTokenResponse {
|
Ok(AccessTokenResponse {
|
||||||
access_token,
|
access_token,
|
||||||
token_type: "bearer".to_string(),
|
token_type: "bearer".to_string(),
|
||||||
|
@ -1125,40 +1253,53 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
})
|
})
|
||||||
.and_then(|data| {
|
.and_then(|data| {
|
||||||
serde_json::from_slice(&data).map_err(|e| {
|
serde_json::from_slice(&data).map_err(|e| {
|
||||||
admin_error!("Failed to deserialise token exchange code - {:?}", e);
|
admin_error!("Failed to deserialise token - {:?}", e);
|
||||||
Oauth2Error::InvalidRequest
|
Oauth2Error::InvalidRequest
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match token {
|
match token {
|
||||||
Oauth2TokenType::Access(at) => {
|
Oauth2TokenType::Access {
|
||||||
|
scopes,
|
||||||
|
parent_session_id,
|
||||||
|
session_id,
|
||||||
|
auth_type: _,
|
||||||
|
expiry,
|
||||||
|
uuid,
|
||||||
|
iat,
|
||||||
|
nbf,
|
||||||
|
auth_time: _,
|
||||||
|
} => {
|
||||||
// Has this token expired?
|
// Has this token expired?
|
||||||
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
||||||
if at.expiry <= odt_ct {
|
if expiry <= odt_ct {
|
||||||
security_info!(?at.uuid, "access token has expired, returning inactive");
|
security_info!(?uuid, "access token has expired, returning inactive");
|
||||||
return Ok(AccessTokenIntrospectResponse::inactive());
|
return Ok(AccessTokenIntrospectResponse::inactive());
|
||||||
}
|
}
|
||||||
let exp = at.iat + ((at.expiry - odt_ct).whole_seconds() as i64);
|
let exp = iat + ((expiry - odt_ct).whole_seconds() as i64);
|
||||||
|
|
||||||
// Is the user expired?
|
// Is the user expired, or the oauth2 session invalid?
|
||||||
let valid = idms
|
let valid = idms
|
||||||
.check_account_uuid_valid(&at.uuid, ct)
|
.check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct)
|
||||||
.map_err(|_| admin_error!("Account is not valid"));
|
.map_err(|_| admin_error!("Account is not valid"));
|
||||||
|
|
||||||
let account = match valid {
|
let account = match valid {
|
||||||
Ok(Some(account)) => account,
|
Ok(Some(account)) => account,
|
||||||
_ => {
|
_ => {
|
||||||
security_info!(?at.uuid, "access token has account not valid, returning inactive");
|
security_info!(
|
||||||
|
?uuid,
|
||||||
|
"access token has account not valid, returning inactive"
|
||||||
|
);
|
||||||
return Ok(AccessTokenIntrospectResponse::inactive());
|
return Ok(AccessTokenIntrospectResponse::inactive());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==== good to generate response ====
|
// ==== good to generate response ====
|
||||||
|
|
||||||
let scope = if at.scopes.is_empty() {
|
let scope = if scopes.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(at.scopes.join(" "))
|
Some(scopes.join(" "))
|
||||||
};
|
};
|
||||||
|
|
||||||
let token_type = Some("access_token".to_string());
|
let token_type = Some("access_token".to_string());
|
||||||
|
@ -1169,15 +1310,15 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
username: Some(account.spn),
|
username: Some(account.spn),
|
||||||
token_type,
|
token_type,
|
||||||
exp: Some(exp),
|
exp: Some(exp),
|
||||||
iat: Some(at.iat),
|
iat: Some(iat),
|
||||||
nbf: Some(at.nbf),
|
nbf: Some(nbf),
|
||||||
sub: Some(at.uuid.to_string()),
|
sub: Some(uuid.to_string()),
|
||||||
aud: Some(client_id),
|
aud: Some(client_id),
|
||||||
iss: None,
|
iss: None,
|
||||||
jti: None,
|
jti: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Oauth2TokenType::Refresh => Ok(AccessTokenIntrospectResponse::inactive()),
|
Oauth2TokenType::Refresh { .. } => Ok(AccessTokenIntrospectResponse::inactive()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1210,29 +1351,42 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match token {
|
match token {
|
||||||
Oauth2TokenType::Access(at) => {
|
Oauth2TokenType::Access {
|
||||||
|
scopes,
|
||||||
|
parent_session_id,
|
||||||
|
session_id,
|
||||||
|
auth_type,
|
||||||
|
expiry,
|
||||||
|
uuid,
|
||||||
|
iat,
|
||||||
|
nbf,
|
||||||
|
auth_time: _,
|
||||||
|
} => {
|
||||||
// Has this token expired?
|
// Has this token expired?
|
||||||
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
||||||
if at.expiry <= odt_ct {
|
if expiry <= odt_ct {
|
||||||
security_info!(?at.uuid, "access token has expired, returning inactive");
|
security_info!(?uuid, "access token has expired, returning inactive");
|
||||||
return Err(Oauth2Error::InvalidToken);
|
return Err(Oauth2Error::InvalidToken);
|
||||||
}
|
}
|
||||||
let exp = at.iat + ((at.expiry - odt_ct).whole_seconds() as i64);
|
let exp = iat + ((expiry - odt_ct).whole_seconds() as i64);
|
||||||
|
|
||||||
// Is the user expired?
|
// Is the user expired, or the oauth2 session invalid?
|
||||||
let valid = idms
|
let valid = idms
|
||||||
.check_account_uuid_valid(&at.uuid, ct)
|
.check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct)
|
||||||
.map_err(|_| admin_error!("Account is not valid"));
|
.map_err(|_| admin_error!("Account is not valid"));
|
||||||
|
|
||||||
let account = match valid {
|
let account = match valid {
|
||||||
Ok(Some(account)) => account,
|
Ok(Some(account)) => account,
|
||||||
_ => {
|
_ => {
|
||||||
security_info!(?at.uuid, "access token has account not valid, returning inactive");
|
security_info!(
|
||||||
|
?uuid,
|
||||||
|
"access token has account not valid, returning inactive"
|
||||||
|
);
|
||||||
return Err(Oauth2Error::InvalidToken);
|
return Err(Oauth2Error::InvalidToken);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (email, email_verified) = if at.scopes.contains(&"email".to_string()) {
|
let (email, email_verified) = if scopes.contains(&"email".to_string()) {
|
||||||
if let Some(mp) = account.mail_primary {
|
if let Some(mp) = account.mail_primary {
|
||||||
(Some(mp), Some(true))
|
(Some(mp), Some(true))
|
||||||
} else {
|
} else {
|
||||||
|
@ -1242,7 +1396,7 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
let amr = Some(vec![at.auth_type.to_string()]);
|
let amr = Some(vec![auth_type.to_string()]);
|
||||||
|
|
||||||
let iss = o2rs.iss.clone();
|
let iss = o2rs.iss.clone();
|
||||||
|
|
||||||
|
@ -1250,10 +1404,10 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
|
|
||||||
Ok(OidcToken {
|
Ok(OidcToken {
|
||||||
iss,
|
iss,
|
||||||
sub: OidcSubject::U(at.uuid),
|
sub: OidcSubject::U(uuid),
|
||||||
aud: client_id.to_string(),
|
aud: client_id.to_string(),
|
||||||
iat: at.iat,
|
iat: iat,
|
||||||
nbf: Some(at.nbf),
|
nbf: Some(nbf),
|
||||||
exp,
|
exp,
|
||||||
auth_time: None,
|
auth_time: None,
|
||||||
nonce: None,
|
nonce: None,
|
||||||
|
@ -1267,7 +1421,7 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
name: Some(account.displayname.clone()),
|
name: Some(account.displayname.clone()),
|
||||||
// Map from spn
|
// Map from spn
|
||||||
preferred_username: Some(account.spn),
|
preferred_username: Some(account.spn),
|
||||||
scopes: at.scopes,
|
scopes: scopes,
|
||||||
email,
|
email,
|
||||||
email_verified,
|
email_verified,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
@ -1276,7 +1430,7 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// https://openid.net/specs/openid-connect-basic-1_0.html#UserInfoErrorResponse
|
// https://openid.net/specs/openid-connect-basic-1_0.html#UserInfoErrorResponse
|
||||||
Oauth2TokenType::Refresh => Err(Oauth2Error::InvalidToken),
|
Oauth2TokenType::Refresh { .. } => Err(Oauth2Error::InvalidToken),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1637,6 +1791,12 @@ mod tests {
|
||||||
.check_oauth2_token_exchange(None, &token_req, ct)
|
.check_oauth2_token_exchange(None, &token_req, ct)
|
||||||
.expect("Failed to perform oauth2 token exchange");
|
.expect("Failed to perform oauth2 token exchange");
|
||||||
|
|
||||||
|
// Assert that the session creation was submitted
|
||||||
|
match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
|
||||||
// 🎉 We got a token! In the future we can then check introspection from this point.
|
// 🎉 We got a token! In the future we can then check introspection from this point.
|
||||||
assert!(token_response.token_type == "bearer");
|
assert!(token_response.token_type == "bearer");
|
||||||
}
|
}
|
||||||
|
@ -2109,6 +2269,12 @@ mod tests {
|
||||||
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
||||||
.expect("Unable to exchange for oauth2 token");
|
.expect("Unable to exchange for oauth2 token");
|
||||||
|
|
||||||
|
// Assert that the session creation was submitted
|
||||||
|
match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
|
||||||
// Okay, now we have the token, we can check it works with introspect.
|
// Okay, now we have the token, we can check it works with introspect.
|
||||||
let intr_request = AccessTokenIntrospectRequest {
|
let intr_request = AccessTokenIntrospectRequest {
|
||||||
token: oauth2_token.access_token.clone(),
|
token: oauth2_token.access_token.clone(),
|
||||||
|
@ -2163,6 +2329,287 @@ mod tests {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idm_oauth2_token_revoke() {
|
||||||
|
run_idm_test!(
|
||||||
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
||||||
|
// First, setup to get a token.
|
||||||
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
||||||
|
let (secret, uat, ident, _) = setup_oauth2_resource_server(idms, ct, true, false);
|
||||||
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
||||||
|
|
||||||
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
||||||
|
|
||||||
|
// == Setup the authorisation request
|
||||||
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
||||||
|
let consent_request =
|
||||||
|
good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge);
|
||||||
|
|
||||||
|
let consent_token =
|
||||||
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
||||||
|
consent_request
|
||||||
|
{
|
||||||
|
consent_token
|
||||||
|
} else {
|
||||||
|
unreachable!();
|
||||||
|
};
|
||||||
|
|
||||||
|
// == Manually submit the consent token to the permit for the permit_success
|
||||||
|
let permit_success = idms_prox_read
|
||||||
|
.check_oauth2_authorise_permit(&ident, &uat, &consent_token, ct)
|
||||||
|
.expect("Failed to perform oauth2 permit");
|
||||||
|
|
||||||
|
// Assert that the consent was submitted
|
||||||
|
match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_req = AccessTokenRequest {
|
||||||
|
grant_type: "authorization_code".to_string(),
|
||||||
|
code: permit_success.code.clone(),
|
||||||
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
||||||
|
client_id: None,
|
||||||
|
client_secret: None,
|
||||||
|
code_verifier,
|
||||||
|
};
|
||||||
|
let oauth2_token = idms_prox_read
|
||||||
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
||||||
|
.expect("Unable to exchange for oauth2 token");
|
||||||
|
|
||||||
|
drop(idms_prox_read);
|
||||||
|
|
||||||
|
// Assert that the session creation was submitted
|
||||||
|
match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2SessionRecord(osr)) => {
|
||||||
|
// Process it to ensure the record exists.
|
||||||
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
||||||
|
|
||||||
|
assert!(idms_prox_write
|
||||||
|
.process_oauth2sessionrecord(&osr)
|
||||||
|
.is_ok());
|
||||||
|
|
||||||
|
assert!(idms_prox_write.commit().is_ok());
|
||||||
|
}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Okay, now we have the token, we can check behaviours with the revoke interface.
|
||||||
|
|
||||||
|
// First, assert it is valid, similar to the introspect api.
|
||||||
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
||||||
|
let intr_request = AccessTokenIntrospectRequest {
|
||||||
|
token: oauth2_token.access_token.clone(),
|
||||||
|
token_type_hint: None,
|
||||||
|
};
|
||||||
|
let intr_response = idms_prox_read
|
||||||
|
.check_oauth2_token_introspect(
|
||||||
|
client_authz.as_deref().unwrap(),
|
||||||
|
&intr_request,
|
||||||
|
ct,
|
||||||
|
)
|
||||||
|
.expect("Failed to inspect token");
|
||||||
|
eprintln!("👉 {:?}", intr_response);
|
||||||
|
assert!(intr_response.active);
|
||||||
|
drop(idms_prox_read);
|
||||||
|
|
||||||
|
// First, the revoke needs basic auth. Provide incorrect auth, and we fail.
|
||||||
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
||||||
|
|
||||||
|
let bad_client_authz = Some(base64::encode("test_resource_server:12345"));
|
||||||
|
let revoke_request = TokenRevokeRequest {
|
||||||
|
token: oauth2_token.access_token.clone(),
|
||||||
|
token_type_hint: None,
|
||||||
|
};
|
||||||
|
let e = idms_prox_write
|
||||||
|
.oauth2_token_revoke(bad_client_authz.as_deref().unwrap(), &revoke_request, ct)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(e, Oauth2Error::AuthenticationRequired));
|
||||||
|
assert!(idms_prox_write.commit().is_ok());
|
||||||
|
|
||||||
|
// Now submit a non-existant/invalid token. Does not affect our tokens validity.
|
||||||
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
||||||
|
let revoke_request = TokenRevokeRequest {
|
||||||
|
token: "this is an invalid token, nothing will happen!".to_string(),
|
||||||
|
token_type_hint: None,
|
||||||
|
};
|
||||||
|
let e = idms_prox_write
|
||||||
|
.oauth2_token_revoke(client_authz.as_deref().unwrap(), &revoke_request, ct)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(e, Oauth2Error::InvalidRequest));
|
||||||
|
assert!(idms_prox_write.commit().is_ok());
|
||||||
|
|
||||||
|
// Check our token is still valid.
|
||||||
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
||||||
|
let intr_response = idms_prox_read
|
||||||
|
.check_oauth2_token_introspect(
|
||||||
|
client_authz.as_deref().unwrap(),
|
||||||
|
&intr_request,
|
||||||
|
ct,
|
||||||
|
)
|
||||||
|
.expect("Failed to inspect token");
|
||||||
|
assert!(intr_response.active);
|
||||||
|
drop(idms_prox_read);
|
||||||
|
|
||||||
|
// Finally revoke it.
|
||||||
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
||||||
|
let revoke_request = TokenRevokeRequest {
|
||||||
|
token: oauth2_token.access_token.clone(),
|
||||||
|
token_type_hint: None,
|
||||||
|
};
|
||||||
|
assert!(idms_prox_write
|
||||||
|
.oauth2_token_revoke(client_authz.as_deref().unwrap(), &revoke_request, ct,)
|
||||||
|
.is_ok());
|
||||||
|
assert!(idms_prox_write.commit().is_ok());
|
||||||
|
|
||||||
|
// Check it is still valid - this is because we are still in the GRACE window.
|
||||||
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
||||||
|
let intr_response = idms_prox_read
|
||||||
|
.check_oauth2_token_introspect(
|
||||||
|
client_authz.as_deref().unwrap(),
|
||||||
|
&intr_request,
|
||||||
|
ct,
|
||||||
|
)
|
||||||
|
.expect("Failed to inspect token");
|
||||||
|
|
||||||
|
assert!(intr_response.active);
|
||||||
|
drop(idms_prox_read);
|
||||||
|
|
||||||
|
// Check after the grace window, it will be invalid.
|
||||||
|
let ct = ct + GRACE_WINDOW;
|
||||||
|
|
||||||
|
// Assert it is now invalid.
|
||||||
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
||||||
|
let intr_response = idms_prox_read
|
||||||
|
.check_oauth2_token_introspect(
|
||||||
|
client_authz.as_deref().unwrap(),
|
||||||
|
&intr_request,
|
||||||
|
ct,
|
||||||
|
)
|
||||||
|
.expect("Failed to inspect token");
|
||||||
|
|
||||||
|
assert!(!intr_response.active);
|
||||||
|
drop(idms_prox_read);
|
||||||
|
|
||||||
|
// A second invalidation of the token "does nothing".
|
||||||
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
||||||
|
let revoke_request = TokenRevokeRequest {
|
||||||
|
token: oauth2_token.access_token.clone(),
|
||||||
|
token_type_hint: None,
|
||||||
|
};
|
||||||
|
assert!(idms_prox_write
|
||||||
|
.oauth2_token_revoke(client_authz.as_deref().unwrap(), &revoke_request, ct,)
|
||||||
|
.is_ok());
|
||||||
|
assert!(idms_prox_write.commit().is_ok());
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idm_oauth2_session_cleanup_post_rs_delete() {
|
||||||
|
run_idm_test!(
|
||||||
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
||||||
|
// First, setup to get a token.
|
||||||
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
||||||
|
let (secret, uat, ident, _) = setup_oauth2_resource_server(idms, ct, true, false);
|
||||||
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
||||||
|
|
||||||
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
||||||
|
|
||||||
|
// == Setup the authorisation request
|
||||||
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
||||||
|
let consent_request =
|
||||||
|
good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge);
|
||||||
|
|
||||||
|
let consent_token =
|
||||||
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
||||||
|
consent_request
|
||||||
|
{
|
||||||
|
consent_token
|
||||||
|
} else {
|
||||||
|
unreachable!();
|
||||||
|
};
|
||||||
|
|
||||||
|
// == Manually submit the consent token to the permit for the permit_success
|
||||||
|
let permit_success = idms_prox_read
|
||||||
|
.check_oauth2_authorise_permit(&ident, &uat, &consent_token, ct)
|
||||||
|
.expect("Failed to perform oauth2 permit");
|
||||||
|
|
||||||
|
// Assert that the consent was submitted
|
||||||
|
match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_req = AccessTokenRequest {
|
||||||
|
grant_type: "authorization_code".to_string(),
|
||||||
|
code: permit_success.code.clone(),
|
||||||
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
||||||
|
client_id: None,
|
||||||
|
client_secret: None,
|
||||||
|
code_verifier,
|
||||||
|
};
|
||||||
|
let _oauth2_token = idms_prox_read
|
||||||
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
||||||
|
.expect("Unable to exchange for oauth2 token");
|
||||||
|
|
||||||
|
drop(idms_prox_read);
|
||||||
|
|
||||||
|
// Process it to ensure the record exists.
|
||||||
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
||||||
|
|
||||||
|
// Assert that the session creation was submitted
|
||||||
|
let session_id = match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2SessionRecord(osr)) => {
|
||||||
|
assert!(idms_prox_write
|
||||||
|
.process_oauth2sessionrecord(&osr)
|
||||||
|
.is_ok());
|
||||||
|
osr.session_id
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
unreachable!();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check it is now there
|
||||||
|
let entry = idms_prox_write
|
||||||
|
.qs_write
|
||||||
|
.internal_search_uuid(&UUID_ADMIN)
|
||||||
|
.expect("failed");
|
||||||
|
let valid = entry
|
||||||
|
.get_ava_as_oauth2session_map("oauth2_session")
|
||||||
|
.map(|map| map.get(&session_id).is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
assert!(valid);
|
||||||
|
|
||||||
|
// Delete the resource server.
|
||||||
|
|
||||||
|
let de = unsafe {
|
||||||
|
DeleteEvent::new_internal_invalid(filter!(f_eq(
|
||||||
|
"oauth2_rs_name",
|
||||||
|
PartialValue::new_iname("test_resource_server")
|
||||||
|
)))
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(idms_prox_write.qs_write.delete(&de).is_ok());
|
||||||
|
|
||||||
|
// Assert the session is gone. This is cleaned up as an artifact of the referential
|
||||||
|
// integrity plugin.
|
||||||
|
let entry = idms_prox_write
|
||||||
|
.qs_write
|
||||||
|
.internal_search_uuid(&UUID_ADMIN)
|
||||||
|
.expect("failed");
|
||||||
|
let valid = entry
|
||||||
|
.get_ava_as_oauth2session_map("oauth2_session")
|
||||||
|
.map(|map| map.get(&session_id).is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
assert!(!valid);
|
||||||
|
|
||||||
|
assert!(idms_prox_write.commit().is_ok());
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_idm_oauth2_authorisation_reject() {
|
fn test_idm_oauth2_authorisation_reject() {
|
||||||
run_idm_test!(|_qs: &QueryServer,
|
run_idm_test!(|_qs: &QueryServer,
|
||||||
|
@ -2439,6 +2886,12 @@ mod tests {
|
||||||
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
||||||
.expect("Failed to perform oauth2 token exchange");
|
.expect("Failed to perform oauth2 token exchange");
|
||||||
|
|
||||||
|
// Assert that the session creation was submitted
|
||||||
|
match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
|
||||||
// 🎉 We got a token!
|
// 🎉 We got a token!
|
||||||
assert!(token_response.token_type == "bearer");
|
assert!(token_response.token_type == "bearer");
|
||||||
|
|
||||||
|
@ -2625,6 +3078,12 @@ mod tests {
|
||||||
.check_oauth2_token_exchange(None, &token_req, ct)
|
.check_oauth2_token_exchange(None, &token_req, ct)
|
||||||
.expect("Failed to perform oauth2 token exchange");
|
.expect("Failed to perform oauth2 token exchange");
|
||||||
|
|
||||||
|
// Assert that the session creation was submitted
|
||||||
|
match idms_delayed.async_rx.blocking_recv() {
|
||||||
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
|
||||||
// 🎉 We got a token!
|
// 🎉 We got a token!
|
||||||
assert!(token_response.token_type == "bearer");
|
assert!(token_response.token_type == "bearer");
|
||||||
let id_token = token_response.id_token.expect("No id_token in response!");
|
let id_token = token_response.id_token.expect("No id_token in response!");
|
||||||
|
|
|
@ -16,7 +16,6 @@ use kanidm_proto::v1::{
|
||||||
UnixGroupToken, UnixUserToken, UserAuthToken,
|
UnixGroupToken, UnixUserToken, UserAuthToken,
|
||||||
};
|
};
|
||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
use time::OffsetDateTime;
|
|
||||||
use tokio::sync::mpsc::{
|
use tokio::sync::mpsc::{
|
||||||
unbounded_channel as unbounded, UnboundedReceiver as Receiver, UnboundedSender as Sender,
|
unbounded_channel as unbounded, UnboundedReceiver as Receiver, UnboundedSender as Sender,
|
||||||
};
|
};
|
||||||
|
@ -33,8 +32,8 @@ use crate::idm::account::Account;
|
||||||
use crate::idm::authsession::AuthSession;
|
use crate::idm::authsession::AuthSession;
|
||||||
use crate::idm::credupdatesession::CredentialUpdateSessionMutex;
|
use crate::idm::credupdatesession::CredentialUpdateSessionMutex;
|
||||||
use crate::idm::delayed::{
|
use crate::idm::delayed::{
|
||||||
AuthSessionRecord, BackupCodeRemoval, DelayedAction, Oauth2ConsentGrant, PasswordUpgrade,
|
AuthSessionRecord, BackupCodeRemoval, DelayedAction, Oauth2ConsentGrant, Oauth2SessionRecord,
|
||||||
UnixPasswordUpgrade, WebauthnCounterIncrement,
|
PasswordUpgrade, UnixPasswordUpgrade, WebauthnCounterIncrement,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::idm::event::PasswordChangeEvent;
|
use crate::idm::event::PasswordChangeEvent;
|
||||||
|
@ -58,7 +57,7 @@ use crate::idm::AuthState;
|
||||||
use crate::ldap::{LdapBoundToken, LdapSession};
|
use crate::ldap::{LdapBoundToken, LdapSession};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::utils::{password_from_random, readable_password_from_random, uuid_from_duration, Sid};
|
use crate::utils::{password_from_random, readable_password_from_random, uuid_from_duration, Sid};
|
||||||
use crate::value::Session;
|
use crate::value::{Oauth2Session, Session};
|
||||||
|
|
||||||
type AuthSessionMutex = Arc<Mutex<AuthSession>>;
|
type AuthSessionMutex = Arc<Mutex<AuthSession>>;
|
||||||
type CredSoftLockMutex = Arc<Mutex<CredSoftLock>>;
|
type CredSoftLockMutex = Arc<Mutex<CredSoftLock>>;
|
||||||
|
@ -135,7 +134,7 @@ pub struct IdmServerProxyWriteTransaction<'a> {
|
||||||
uat_jwt_signer: CowCellWriteTxn<'a, JwsSigner>,
|
uat_jwt_signer: CowCellWriteTxn<'a, JwsSigner>,
|
||||||
uat_jwt_validator: CowCellWriteTxn<'a, JwsValidator>,
|
uat_jwt_validator: CowCellWriteTxn<'a, JwsValidator>,
|
||||||
pub(crate) token_enc_key: CowCellWriteTxn<'a, Fernet>,
|
pub(crate) token_enc_key: CowCellWriteTxn<'a, Fernet>,
|
||||||
oauth2rs: Oauth2ResourceServersWriteTransaction<'a>,
|
pub(crate) oauth2rs: Oauth2ResourceServersWriteTransaction<'a>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct IdmServerDelayed {
|
pub struct IdmServerDelayed {
|
||||||
|
@ -574,25 +573,54 @@ pub trait IdmServerTransaction<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_account_uuid_valid(
|
fn check_oauth2_account_uuid_valid(
|
||||||
&self,
|
&self,
|
||||||
uuid: &Uuid,
|
uuid: Uuid,
|
||||||
|
session_id: Uuid,
|
||||||
|
parent_session_id: Uuid,
|
||||||
|
iat: i64,
|
||||||
ct: Duration,
|
ct: Duration,
|
||||||
) -> Result<Option<Account>, OperationError> {
|
) -> Result<Option<Account>, OperationError> {
|
||||||
let entry = self.get_qs_txn().internal_search_uuid(uuid).map_err(|e| {
|
let entry = self.get_qs_txn().internal_search_uuid(&uuid).map_err(|e| {
|
||||||
admin_error!(?e, "check_account_uuid_valid failed");
|
admin_error!(?e, "check_oauth2_account_uuid_valid failed");
|
||||||
e
|
e
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if Account::check_within_valid_time(
|
let within_valid_window = Account::check_within_valid_time(
|
||||||
ct,
|
ct,
|
||||||
entry.get_ava_single_datetime("account_valid_from").as_ref(),
|
entry.get_ava_single_datetime("account_valid_from").as_ref(),
|
||||||
entry.get_ava_single_datetime("account_expire").as_ref(),
|
entry.get_ava_single_datetime("account_expire").as_ref(),
|
||||||
) {
|
);
|
||||||
Account::try_from_entry_no_groups(entry.as_ref()).map(Some)
|
|
||||||
} else {
|
if !within_valid_window {
|
||||||
Ok(None)
|
security_info!("Account has expired or is not yet valid, not allowing to proceed");
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ct >= Duration::from_secs(iat as u64) + GRACE_WINDOW {
|
||||||
|
// We are past the grace window. Enforce session presence.
|
||||||
|
// We enforce both sessions are present in case of inconsistency
|
||||||
|
// that may occur with replication.
|
||||||
|
let oauth2_session_valid = entry
|
||||||
|
.get_ava_as_oauth2session_map("oauth2_session")
|
||||||
|
.map(|map| map.get(&session_id).is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let uat_session_valid = entry
|
||||||
|
.get_ava_as_session_map("user_auth_token_session")
|
||||||
|
.map(|map| map.get(&parent_session_id).is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if oauth2_session_valid && uat_session_valid {
|
||||||
|
security_info!("A valid session value exists for this token");
|
||||||
|
} else {
|
||||||
|
security_info!(%uat_session_valid, %oauth2_session_valid, "The token grace window has passed and no sessions exist. Assuming invalid.");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
security_info!("The token grace window is in effect. Assuming valid.");
|
||||||
|
};
|
||||||
|
|
||||||
|
Account::try_from_entry_no_groups(entry.as_ref()).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For any event/operation to proceed, we need to attach an identity to the
|
/// For any event/operation to proceed, we need to attach an identity to the
|
||||||
|
@ -1528,7 +1556,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
ct: Duration,
|
ct: Duration,
|
||||||
) -> Result<AccessTokenResponse, Oauth2Error> {
|
) -> Result<AccessTokenResponse, Oauth2Error> {
|
||||||
self.oauth2rs
|
self.oauth2rs
|
||||||
.check_oauth2_token_exchange(client_authz, token_req, ct)
|
.check_oauth2_token_exchange(client_authz, token_req, ct, &self.async_tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_oauth2_token_introspect(
|
pub fn check_oauth2_token_introspect(
|
||||||
|
@ -2103,13 +2131,9 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
pub(crate) fn process_authsessionrecord(
|
pub(crate) fn process_authsessionrecord(
|
||||||
&mut self,
|
&mut self,
|
||||||
asr: &AuthSessionRecord,
|
asr: &AuthSessionRecord,
|
||||||
ct: Duration,
|
|
||||||
) -> Result<(), OperationError> {
|
) -> Result<(), OperationError> {
|
||||||
// We have to get the entry so we can work out if we need to expire any of it's sessions.
|
// We have to get the entry so we can work out if we need to expire any of it's sessions.
|
||||||
|
|
||||||
let entry = self.qs_write.internal_search_uuid(&asr.target_uuid)?;
|
|
||||||
let sessions = entry.get_ava_as_session_map("user_auth_token_session");
|
|
||||||
|
|
||||||
let session = Value::Session(
|
let session = Value::Session(
|
||||||
asr.session_id,
|
asr.session_id,
|
||||||
Session {
|
Session {
|
||||||
|
@ -2128,33 +2152,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
|
|
||||||
info!(session_id = %asr.session_id, "Persisting auth session");
|
info!(session_id = %asr.session_id, "Persisting auth session");
|
||||||
|
|
||||||
let offset_ct = OffsetDateTime::unix_epoch() + ct;
|
|
||||||
|
|
||||||
let mlist: Vec<_> = sessions
|
|
||||||
.iter()
|
|
||||||
.flat_map(|item| item.iter())
|
|
||||||
.filter_map(|(k, v)| {
|
|
||||||
// We only check if an expiry is present
|
|
||||||
v.expiry.and_then(|exp| {
|
|
||||||
if exp <= offset_ct {
|
|
||||||
info!(session_id = %k, "Removing expired auth session");
|
|
||||||
Some(Modify::Removed(
|
|
||||||
AttrString::from("user_auth_token_session"),
|
|
||||||
PartialValue::Refer(*k),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.chain(std::iter::once(Modify::Present(
|
|
||||||
AttrString::from("user_auth_token_session"),
|
|
||||||
session,
|
|
||||||
)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// modify the account to put the session onto it.
|
// modify the account to put the session onto it.
|
||||||
let modlist = ModifyList::new_list(mlist);
|
let modlist = ModifyList::new_append("user_auth_token_session", session);
|
||||||
|
|
||||||
self.qs_write
|
self.qs_write
|
||||||
.internal_modify(
|
.internal_modify(
|
||||||
|
@ -2189,10 +2188,44 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn process_oauth2sessionrecord(
|
||||||
|
&mut self,
|
||||||
|
osr: &Oauth2SessionRecord,
|
||||||
|
) -> Result<(), OperationError> {
|
||||||
|
let session = Value::Oauth2Session(
|
||||||
|
osr.session_id,
|
||||||
|
Oauth2Session {
|
||||||
|
parent: osr.parent_session_id,
|
||||||
|
expiry: osr.expiry,
|
||||||
|
issued_at: osr.issued_at,
|
||||||
|
rs_uuid: osr.rs_uuid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
info!(session_id = %osr.session_id, "Persisting auth session");
|
||||||
|
|
||||||
|
// modify the account to put the session onto it.
|
||||||
|
let modlist = ModifyList::new_append(
|
||||||
|
"oauth2_session",
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
|
||||||
|
self.qs_write
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(osr.target_uuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
admin_error!("Failed to persist user auth token {:?}", e);
|
||||||
|
e
|
||||||
|
})
|
||||||
|
// Done!
|
||||||
|
}
|
||||||
|
|
||||||
pub fn process_delayedaction(
|
pub fn process_delayedaction(
|
||||||
&mut self,
|
&mut self,
|
||||||
da: DelayedAction,
|
da: DelayedAction,
|
||||||
ct: Duration,
|
_ct: Duration,
|
||||||
) -> Result<(), OperationError> {
|
) -> Result<(), OperationError> {
|
||||||
match da {
|
match da {
|
||||||
DelayedAction::PwUpgrade(pwu) => self.process_pwupgrade(&pwu),
|
DelayedAction::PwUpgrade(pwu) => self.process_pwupgrade(&pwu),
|
||||||
|
@ -2200,7 +2233,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
DelayedAction::WebauthnCounterIncrement(wci) => self.process_webauthncounterinc(&wci),
|
DelayedAction::WebauthnCounterIncrement(wci) => self.process_webauthncounterinc(&wci),
|
||||||
DelayedAction::BackupCodeRemoval(bcr) => self.process_backupcoderemoval(&bcr),
|
DelayedAction::BackupCodeRemoval(bcr) => self.process_backupcoderemoval(&bcr),
|
||||||
DelayedAction::Oauth2ConsentGrant(o2cg) => self.process_oauth2consentgrant(&o2cg),
|
DelayedAction::Oauth2ConsentGrant(o2cg) => self.process_oauth2consentgrant(&o2cg),
|
||||||
DelayedAction::AuthSessionRecord(asr) => self.process_authsessionrecord(&asr, ct),
|
DelayedAction::AuthSessionRecord(asr) => self.process_authsessionrecord(&asr),
|
||||||
|
DelayedAction::Oauth2SessionRecord(osr) => self.process_oauth2sessionrecord(&osr),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2307,7 +2341,6 @@ mod tests {
|
||||||
const TEST_PASSWORD: &'static str = "ntaoeuntnaoeuhraohuercahu😍";
|
const TEST_PASSWORD: &'static str = "ntaoeuntnaoeuhraohuercahu😍";
|
||||||
const TEST_PASSWORD_INC: &'static str = "ntaoentu nkrcgaeunhibwmwmqj;k wqjbkx ";
|
const TEST_PASSWORD_INC: &'static str = "ntaoentu nkrcgaeunhibwmwmqj;k wqjbkx ";
|
||||||
const TEST_CURRENT_TIME: u64 = 6000;
|
const TEST_CURRENT_TIME: u64 = 6000;
|
||||||
const TEST_CURRENT_EXPIRE: u64 = TEST_CURRENT_TIME + AUTH_SESSION_TIMEOUT + 1;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_idm_anonymous_auth() {
|
fn test_idm_anonymous_auth() {
|
||||||
|
@ -2742,36 +2775,6 @@ mod tests {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_idm_session_expire() {
|
|
||||||
run_idm_test!(
|
|
||||||
|qs: &QueryServer, idms: &IdmServer, _idms_delayed: &IdmServerDelayed| {
|
|
||||||
task::block_on(init_admin_w_password(qs, TEST_PASSWORD))
|
|
||||||
.expect("Failed to setup admin account");
|
|
||||||
let sid = init_admin_authsession_sid(
|
|
||||||
idms,
|
|
||||||
Duration::from_secs(TEST_CURRENT_TIME),
|
|
||||||
"admin",
|
|
||||||
);
|
|
||||||
let mut idms_auth = idms.auth();
|
|
||||||
assert!(idms_auth.is_sessionid_present(&sid));
|
|
||||||
// Expire like we are currently "now". Should not affect our session.
|
|
||||||
task::block_on(
|
|
||||||
idms_auth.expire_auth_sessions(Duration::from_secs(TEST_CURRENT_TIME)),
|
|
||||||
);
|
|
||||||
assert!(idms_auth.is_sessionid_present(&sid));
|
|
||||||
// Expire as though we are in the future.
|
|
||||||
task::block_on(
|
|
||||||
idms_auth.expire_auth_sessions(Duration::from_secs(TEST_CURRENT_EXPIRE)),
|
|
||||||
);
|
|
||||||
assert!(!idms_auth.is_sessionid_present(&sid));
|
|
||||||
assert!(idms_auth.commit().is_ok());
|
|
||||||
let idms_auth = idms.auth();
|
|
||||||
assert!(!idms_auth.is_sessionid_present(&sid));
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_idm_regenerate_radius_secret() {
|
fn test_idm_regenerate_radius_secret() {
|
||||||
run_idm_test!(
|
run_idm_test!(
|
||||||
|
@ -3728,7 +3731,8 @@ mod tests {
|
||||||
idms: &IdmServer,
|
idms: &IdmServer,
|
||||||
_idms_delayed: &mut IdmServerDelayed| {
|
_idms_delayed: &mut IdmServerDelayed| {
|
||||||
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
||||||
let expiry = ct + Duration::from_secs(AUTH_SESSION_EXPIRY + 1);
|
let expiry_a = ct + Duration::from_secs(AUTH_SESSION_EXPIRY + 1);
|
||||||
|
let expiry_b = ct + Duration::from_secs((AUTH_SESSION_EXPIRY + 1) * 2);
|
||||||
|
|
||||||
let session_a = Uuid::new_v4();
|
let session_a = Uuid::new_v4();
|
||||||
let session_b = Uuid::new_v4();
|
let session_b = Uuid::new_v4();
|
||||||
|
@ -3747,7 +3751,7 @@ mod tests {
|
||||||
target_uuid: UUID_ADMIN,
|
target_uuid: UUID_ADMIN,
|
||||||
session_id: session_a,
|
session_id: session_a,
|
||||||
label: "Test Session A".to_string(),
|
label: "Test Session A".to_string(),
|
||||||
expiry: Some(OffsetDateTime::unix_epoch() + expiry),
|
expiry: Some(OffsetDateTime::unix_epoch() + expiry_a),
|
||||||
issued_at: OffsetDateTime::unix_epoch() + ct,
|
issued_at: OffsetDateTime::unix_epoch() + ct,
|
||||||
issued_by: IdentityId::User(UUID_ADMIN),
|
issued_by: IdentityId::User(UUID_ADMIN),
|
||||||
scope: AccessScope::IdentityOnly,
|
scope: AccessScope::IdentityOnly,
|
||||||
|
@ -3781,13 +3785,13 @@ mod tests {
|
||||||
target_uuid: UUID_ADMIN,
|
target_uuid: UUID_ADMIN,
|
||||||
session_id: session_b,
|
session_id: session_b,
|
||||||
label: "Test Session B".to_string(),
|
label: "Test Session B".to_string(),
|
||||||
expiry: Some(OffsetDateTime::unix_epoch() + expiry),
|
expiry: Some(OffsetDateTime::unix_epoch() + expiry_b),
|
||||||
issued_at: OffsetDateTime::unix_epoch() + ct,
|
issued_at: OffsetDateTime::unix_epoch() + ct,
|
||||||
issued_by: IdentityId::User(UUID_ADMIN),
|
issued_by: IdentityId::User(UUID_ADMIN),
|
||||||
scope: AccessScope::IdentityOnly,
|
scope: AccessScope::IdentityOnly,
|
||||||
});
|
});
|
||||||
// Persist it.
|
// Persist it.
|
||||||
let r = task::block_on(idms.delayed_action(expiry, da));
|
let r = task::block_on(idms.delayed_action(expiry_a, da));
|
||||||
assert!(Ok(true) == r);
|
assert!(Ok(true) == r);
|
||||||
|
|
||||||
let idms_prox_read = task::block_on(idms.proxy_read());
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
||||||
|
@ -3836,7 +3840,7 @@ mod tests {
|
||||||
// Process the session info.
|
// Process the session info.
|
||||||
let da = idms_delayed.try_recv().expect("invalid");
|
let da = idms_delayed.try_recv().expect("invalid");
|
||||||
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
|
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
|
||||||
let r = task::block_on(idms.delayed_action(duration_from_epoch_now(), da));
|
let r = task::block_on(idms.delayed_action(ct, da));
|
||||||
assert!(Ok(true) == r);
|
assert!(Ok(true) == r);
|
||||||
|
|
||||||
let uat_unverified =
|
let uat_unverified =
|
||||||
|
|
|
@ -21,6 +21,7 @@ mod memberof;
|
||||||
mod password_import;
|
mod password_import;
|
||||||
mod protected;
|
mod protected;
|
||||||
mod refint;
|
mod refint;
|
||||||
|
mod session;
|
||||||
mod spn;
|
mod spn;
|
||||||
|
|
||||||
trait Plugin {
|
trait Plugin {
|
||||||
|
@ -165,6 +166,7 @@ impl Plugins {
|
||||||
.and_then(|_| gidnumber::GidNumber::pre_modify(qs, cand, me))
|
.and_then(|_| gidnumber::GidNumber::pre_modify(qs, cand, me))
|
||||||
.and_then(|_| domain::Domain::pre_modify(qs, cand, me))
|
.and_then(|_| domain::Domain::pre_modify(qs, cand, me))
|
||||||
.and_then(|_| spn::Spn::pre_modify(qs, cand, me))
|
.and_then(|_| spn::Spn::pre_modify(qs, cand, me))
|
||||||
|
.and_then(|_| session::SessionConsistency::pre_modify(qs, cand, me))
|
||||||
// attr unique should always be last
|
// attr unique should always be last
|
||||||
.and_then(|_| attrunique::AttrUnique::pre_modify(qs, cand, me))
|
.and_then(|_| attrunique::AttrUnique::pre_modify(qs, cand, me))
|
||||||
}
|
}
|
||||||
|
|
|
@ -146,7 +146,7 @@ impl Plugin for ReferentialIntegrity {
|
||||||
})
|
})
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
v.to_ref_uuid()
|
v.to_ref_uuid()
|
||||||
.map(|uuid| PartialValue::new_uuid(*uuid))
|
.map(|uuid| PartialValue::new_uuid(uuid))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
admin_error!(?v, "reference value could not convert to reference uuid.");
|
admin_error!(?v, "reference value could not convert to reference uuid.");
|
||||||
admin_error!("If you are sure the name/uuid/spn exist, and that this is in error, you should run a verify task.");
|
admin_error!("If you are sure the name/uuid/spn exist, and that this is in error, you should run a verify task.");
|
||||||
|
@ -267,7 +267,11 @@ impl Plugin for ReferentialIntegrity {
|
||||||
mod tests {
|
mod tests {
|
||||||
use kanidm_proto::v1::PluginError;
|
use kanidm_proto::v1::PluginError;
|
||||||
|
|
||||||
|
use crate::event::CreateEvent;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use crate::value::{Oauth2Session, Session};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::uuid;
|
||||||
|
|
||||||
// The create references a uuid that doesn't exist - reject
|
// The create references a uuid that doesn't exist - reject
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -775,4 +779,121 @@ mod tests {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[qs_test]
|
||||||
|
async fn test_delete_oauth2_rs_remove_sessions(server: &QueryServer) {
|
||||||
|
let curtime = duration_from_epoch_now();
|
||||||
|
let curtime_odt = OffsetDateTime::unix_epoch() + curtime;
|
||||||
|
|
||||||
|
// Create a user
|
||||||
|
let mut server_txn = server.write(curtime).await;
|
||||||
|
|
||||||
|
let tuuid = uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930");
|
||||||
|
let rs_uuid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let e1 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("person")),
|
||||||
|
("class", Value::new_class("account")),
|
||||||
|
("name", Value::new_iname("testperson1")),
|
||||||
|
("uuid", Value::new_uuid(tuuid)),
|
||||||
|
("description", Value::new_utf8s("testperson1")),
|
||||||
|
("displayname", Value::new_utf8s("testperson1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let e2 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server_basic")),
|
||||||
|
("uuid", Value::new_uuid(rs_uuid)),
|
||||||
|
("oauth2_rs_name", Value::new_iname("test_resource_server")),
|
||||||
|
("displayname", Value::new_utf8s("test_resource_server")),
|
||||||
|
(
|
||||||
|
"oauth2_rs_origin",
|
||||||
|
Value::new_url_s("https://demo.example.com").unwrap()
|
||||||
|
),
|
||||||
|
// System admins
|
||||||
|
(
|
||||||
|
"oauth2_rs_scope_map",
|
||||||
|
Value::new_oauthscopemap(UUID_IDM_ALL_ACCOUNTS, btreeset!["openid".to_string()])
|
||||||
|
.expect("invalid oauthscope")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let ce = CreateEvent::new_internal(vec![e1, e2]);
|
||||||
|
assert!(server_txn.create(&ce).is_ok());
|
||||||
|
|
||||||
|
// Create a fake session and oauth2 session.
|
||||||
|
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let pv_session_id = PartialValue::new_refer(session_id);
|
||||||
|
|
||||||
|
let parent = Uuid::new_v4();
|
||||||
|
let pv_parent_id = PartialValue::new_refer(parent);
|
||||||
|
let issued_at = curtime_odt;
|
||||||
|
let issued_by = IdentityId::User(tuuid);
|
||||||
|
let scope = AccessScope::IdentityOnly;
|
||||||
|
|
||||||
|
// Mod the user
|
||||||
|
let modlist = modlist!([
|
||||||
|
Modify::Present(
|
||||||
|
"oauth2_session".into(),
|
||||||
|
Value::Oauth2Session(
|
||||||
|
session_id,
|
||||||
|
Oauth2Session {
|
||||||
|
parent,
|
||||||
|
// Note we set the exp to None so we are not removing based on exp
|
||||||
|
expiry: None,
|
||||||
|
issued_at,
|
||||||
|
rs_uuid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Modify::Present(
|
||||||
|
"user_auth_token_session".into(),
|
||||||
|
Value::Session(
|
||||||
|
parent,
|
||||||
|
Session {
|
||||||
|
label: "label".to_string(),
|
||||||
|
// Note we set the exp to None so we are not removing based on removal of the parent.
|
||||||
|
expiry: None,
|
||||||
|
// Need the other inner bits?
|
||||||
|
// for the gracewindow.
|
||||||
|
issued_at,
|
||||||
|
// Who actually created this?
|
||||||
|
issued_by,
|
||||||
|
// What is the access scope of this session? This is
|
||||||
|
// for auditing purposes.
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Still there
|
||||||
|
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
assert!(entry.attribute_equality("user_auth_token_session", &pv_parent_id));
|
||||||
|
assert!(entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
// Delete the oauth2 resource server.
|
||||||
|
assert!(server_txn.internal_delete_uuid(rs_uuid).is_ok());
|
||||||
|
|
||||||
|
// Oauth2 Session gone.
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
// Note the uat is present still.
|
||||||
|
assert!(entry.attribute_equality("user_auth_token_session", &pv_parent_id));
|
||||||
|
// The oauth2 session is removed.
|
||||||
|
assert!(!entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
575
kanidmd/lib/src/plugins/session.rs
Normal file
575
kanidmd/lib/src/plugins/session.rs
Normal file
|
@ -0,0 +1,575 @@
|
||||||
|
//! This plugin maintains consistency of authenticated sessions on accounts.
|
||||||
|
//!
|
||||||
|
//! An example of this is that oauth2 sessions are child of user auth sessions,
|
||||||
|
//! such than when the user auth session is terminated, then the corresponding
|
||||||
|
//! oauth2 session should also be terminated.
|
||||||
|
//!
|
||||||
|
//! This plugin is also responsible for invaliding old sessions that are past
|
||||||
|
//! their expiry.
|
||||||
|
|
||||||
|
use crate::event::ModifyEvent;
|
||||||
|
use crate::plugins::Plugin;
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
pub struct SessionConsistency {}
|
||||||
|
|
||||||
|
impl Plugin for SessionConsistency {
|
||||||
|
fn id() -> &'static str {
|
||||||
|
"plugin_session_consistency"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(level = "debug", name = "session_consistency", skip_all)]
|
||||||
|
fn pre_modify(
|
||||||
|
qs: &mut QueryServerWriteTransaction,
|
||||||
|
cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>,
|
||||||
|
_me: &ModifyEvent,
|
||||||
|
) -> Result<(), OperationError> {
|
||||||
|
let curtime = qs.get_curtime();
|
||||||
|
let curtime_odt = OffsetDateTime::unix_epoch() + curtime;
|
||||||
|
|
||||||
|
// We need to assert a number of properties. We must do these *in order*.
|
||||||
|
cand.iter_mut().try_for_each(|entry| {
|
||||||
|
// * If a UAT is past it's expiry, remove it.
|
||||||
|
let expired: Option<BTreeSet<_>> = entry.get_ava_as_session_map("user_auth_token_session")
|
||||||
|
.map(|sessions| {
|
||||||
|
sessions.iter().filter_map(|(session_id, session)| {
|
||||||
|
match &session.expiry {
|
||||||
|
Some(exp) if exp <= &curtime_odt => {
|
||||||
|
info!(%session_id, "Removing expired auth session");
|
||||||
|
Some(PartialValue::Refer(*session_id))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(expired) = expired.as_ref() {
|
||||||
|
entry.remove_avas("user_auth_token_session", expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
// * If an oauth2 session is past it's expiry, remove it.
|
||||||
|
// * If an oauth2 session is past the grace window, and no parent session exists, remove it.
|
||||||
|
let oauth2_remove: Option<BTreeSet<_>> = entry.get_ava_as_oauth2session_map("oauth2_session").map(|oauth2_sessions| {
|
||||||
|
// If we have oauth2 sessions, we need to be able to lookup if sessions exist in the uat.
|
||||||
|
let sessions = entry.get_ava_as_session_map("user_auth_token_session");
|
||||||
|
|
||||||
|
oauth2_sessions.iter().filter_map(|(o2_session_id, session)| {
|
||||||
|
match &session.expiry {
|
||||||
|
Some(exp) if exp <= &curtime_odt => {
|
||||||
|
info!(%o2_session_id, "Removing expired oauth2 session");
|
||||||
|
Some(PartialValue::Refer(*o2_session_id))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Okay, now check the issued / grace time for parent enforcement.
|
||||||
|
if session.issued_at + GRACE_WINDOW <= curtime_odt {
|
||||||
|
if sessions.map(|s| s.contains_key(&session.parent)).unwrap_or(false) {
|
||||||
|
// The parent exists, go ahead
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
info!(%o2_session_id, "Removing unbound oauth2 session");
|
||||||
|
Some(PartialValue::Refer(*o2_session_id))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Grace window is still in effect
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(oauth2_remove) = oauth2_remove.as_ref() {
|
||||||
|
entry.remove_avas("oauth2_session", oauth2_remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
// use kanidm_proto::v1::PluginError;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
use crate::event::CreateEvent;
|
||||||
|
use crate::value::{Oauth2Session, Session};
|
||||||
|
use std::time::Duration;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::uuid;
|
||||||
|
|
||||||
|
// Test expiry of old sessions
|
||||||
|
|
||||||
|
#[qs_test]
|
||||||
|
async fn test_session_consistency_expire_old_sessions(server: &QueryServer) {
|
||||||
|
let curtime = duration_from_epoch_now();
|
||||||
|
let curtime_odt = OffsetDateTime::unix_epoch() + curtime;
|
||||||
|
|
||||||
|
let exp_curtime = curtime + Duration::from_secs(60);
|
||||||
|
let exp_curtime_odt = OffsetDateTime::unix_epoch() + exp_curtime;
|
||||||
|
|
||||||
|
// Create a user
|
||||||
|
let mut server_txn = server.write(curtime).await;
|
||||||
|
|
||||||
|
let tuuid = uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930");
|
||||||
|
|
||||||
|
let e1 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("person")),
|
||||||
|
("class", Value::new_class("account")),
|
||||||
|
("name", Value::new_iname("testperson1")),
|
||||||
|
("uuid", Value::new_uuid(tuuid)),
|
||||||
|
("description", Value::new_utf8s("testperson1")),
|
||||||
|
("displayname", Value::new_utf8s("testperson1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let ce = CreateEvent::new_internal(vec![e1]);
|
||||||
|
assert!(server_txn.create(&ce).is_ok());
|
||||||
|
|
||||||
|
// Create a fake session.
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let pv_session_id = PartialValue::new_refer(session_id);
|
||||||
|
let expiry = Some(exp_curtime_odt);
|
||||||
|
let issued_at = curtime_odt;
|
||||||
|
let issued_by = IdentityId::User(tuuid);
|
||||||
|
let scope = AccessScope::IdentityOnly;
|
||||||
|
|
||||||
|
let session = Value::Session(
|
||||||
|
session_id,
|
||||||
|
Session {
|
||||||
|
label: "label".to_string(),
|
||||||
|
expiry,
|
||||||
|
// Need the other inner bits?
|
||||||
|
// for the gracewindow.
|
||||||
|
issued_at,
|
||||||
|
// Who actually created this?
|
||||||
|
issued_by,
|
||||||
|
// What is the access scope of this session? This is
|
||||||
|
// for auditing purposes.
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mod the user
|
||||||
|
let modlist = ModifyList::new_append("user_auth_token_session", session);
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Still there
|
||||||
|
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
assert!(entry.attribute_equality("user_auth_token_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
let mut server_txn = server.write(exp_curtime).await;
|
||||||
|
|
||||||
|
// Mod again - anything will do.
|
||||||
|
let modlist =
|
||||||
|
ModifyList::new_purge_and_set("description", Value::new_utf8s("test person 1 change"));
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Session gone.
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
// Note it's a not condition now.
|
||||||
|
assert!(!entry.attribute_equality("user_auth_token_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test expiry of old oauth2 sessions
|
||||||
|
#[qs_test]
|
||||||
|
async fn test_session_consistency_oauth2_expiry_cleanup(server: &QueryServer) {
|
||||||
|
let curtime = duration_from_epoch_now();
|
||||||
|
let curtime_odt = OffsetDateTime::unix_epoch() + curtime;
|
||||||
|
|
||||||
|
// Set exp to gracewindow.
|
||||||
|
let exp_curtime = curtime + GRACE_WINDOW;
|
||||||
|
let exp_curtime_odt = OffsetDateTime::unix_epoch() + exp_curtime;
|
||||||
|
|
||||||
|
// Create a user
|
||||||
|
let mut server_txn = server.write(curtime).await;
|
||||||
|
|
||||||
|
let tuuid = uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930");
|
||||||
|
let rs_uuid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let e1 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("person")),
|
||||||
|
("class", Value::new_class("account")),
|
||||||
|
("name", Value::new_iname("testperson1")),
|
||||||
|
("uuid", Value::new_uuid(tuuid)),
|
||||||
|
("description", Value::new_utf8s("testperson1")),
|
||||||
|
("displayname", Value::new_utf8s("testperson1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let e2 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server_basic")),
|
||||||
|
("uuid", Value::new_uuid(rs_uuid)),
|
||||||
|
("oauth2_rs_name", Value::new_iname("test_resource_server")),
|
||||||
|
("displayname", Value::new_utf8s("test_resource_server")),
|
||||||
|
(
|
||||||
|
"oauth2_rs_origin",
|
||||||
|
Value::new_url_s("https://demo.example.com").unwrap()
|
||||||
|
),
|
||||||
|
// System admins
|
||||||
|
(
|
||||||
|
"oauth2_rs_scope_map",
|
||||||
|
Value::new_oauthscopemap(UUID_IDM_ALL_ACCOUNTS, btreeset!["openid".to_string()])
|
||||||
|
.expect("invalid oauthscope")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let ce = CreateEvent::new_internal(vec![e1, e2]);
|
||||||
|
assert!(server_txn.create(&ce).is_ok());
|
||||||
|
|
||||||
|
// Create a fake session and oauth2 session.
|
||||||
|
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let pv_session_id = PartialValue::new_refer(session_id);
|
||||||
|
|
||||||
|
let parent = Uuid::new_v4();
|
||||||
|
let pv_parent_id = PartialValue::new_refer(parent);
|
||||||
|
let expiry = Some(exp_curtime_odt);
|
||||||
|
let issued_at = curtime_odt;
|
||||||
|
let issued_by = IdentityId::User(tuuid);
|
||||||
|
let scope = AccessScope::IdentityOnly;
|
||||||
|
|
||||||
|
// Mod the user
|
||||||
|
let modlist = modlist!([
|
||||||
|
Modify::Present(
|
||||||
|
"oauth2_session".into(),
|
||||||
|
Value::Oauth2Session(
|
||||||
|
session_id,
|
||||||
|
Oauth2Session {
|
||||||
|
parent,
|
||||||
|
// Set to the exp window.
|
||||||
|
expiry,
|
||||||
|
issued_at,
|
||||||
|
rs_uuid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Modify::Present(
|
||||||
|
"user_auth_token_session".into(),
|
||||||
|
Value::Session(
|
||||||
|
parent,
|
||||||
|
Session {
|
||||||
|
label: "label".to_string(),
|
||||||
|
// Note we set the exp to None so we are not removing based on removal of the parent.
|
||||||
|
expiry: None,
|
||||||
|
// Need the other inner bits?
|
||||||
|
// for the gracewindow.
|
||||||
|
issued_at,
|
||||||
|
// Who actually created this?
|
||||||
|
issued_by,
|
||||||
|
// What is the access scope of this session? This is
|
||||||
|
// for auditing purposes.
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Still there
|
||||||
|
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
assert!(entry.attribute_equality("user_auth_token_session", &pv_parent_id));
|
||||||
|
assert!(entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
|
||||||
|
// Note as we are now past exp time, the oauth2 session will be removed, but the uat session
|
||||||
|
// will remain.
|
||||||
|
let mut server_txn = server.write(exp_curtime).await;
|
||||||
|
|
||||||
|
// Mod again - anything will do.
|
||||||
|
let modlist =
|
||||||
|
ModifyList::new_purge_and_set("description", Value::new_utf8s("test person 1 change"));
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Session gone.
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
// Note the uat is still present
|
||||||
|
assert!(entry.attribute_equality("user_auth_token_session", &pv_parent_id));
|
||||||
|
// Note it's a not condition now.
|
||||||
|
assert!(!entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// test removal of a session removes related oauth2 sessions.
|
||||||
|
#[qs_test]
|
||||||
|
async fn test_session_consistency_oauth2_removed_by_parent(server: &QueryServer) {
|
||||||
|
let curtime = duration_from_epoch_now();
|
||||||
|
let curtime_odt = OffsetDateTime::unix_epoch() + curtime;
|
||||||
|
let exp_curtime = curtime + GRACE_WINDOW;
|
||||||
|
|
||||||
|
// Create a user
|
||||||
|
let mut server_txn = server.write(curtime).await;
|
||||||
|
|
||||||
|
let tuuid = uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930");
|
||||||
|
let rs_uuid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let e1 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("person")),
|
||||||
|
("class", Value::new_class("account")),
|
||||||
|
("name", Value::new_iname("testperson1")),
|
||||||
|
("uuid", Value::new_uuid(tuuid)),
|
||||||
|
("description", Value::new_utf8s("testperson1")),
|
||||||
|
("displayname", Value::new_utf8s("testperson1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let e2 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server_basic")),
|
||||||
|
("uuid", Value::new_uuid(rs_uuid)),
|
||||||
|
("oauth2_rs_name", Value::new_iname("test_resource_server")),
|
||||||
|
("displayname", Value::new_utf8s("test_resource_server")),
|
||||||
|
(
|
||||||
|
"oauth2_rs_origin",
|
||||||
|
Value::new_url_s("https://demo.example.com").unwrap()
|
||||||
|
),
|
||||||
|
// System admins
|
||||||
|
(
|
||||||
|
"oauth2_rs_scope_map",
|
||||||
|
Value::new_oauthscopemap(UUID_IDM_ALL_ACCOUNTS, btreeset!["openid".to_string()])
|
||||||
|
.expect("invalid oauthscope")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let ce = CreateEvent::new_internal(vec![e1, e2]);
|
||||||
|
assert!(server_txn.create(&ce).is_ok());
|
||||||
|
|
||||||
|
// Create a fake session and oauth2 session.
|
||||||
|
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let pv_session_id = PartialValue::new_refer(session_id);
|
||||||
|
|
||||||
|
let parent = Uuid::new_v4();
|
||||||
|
let pv_parent_id = PartialValue::new_refer(parent);
|
||||||
|
let issued_at = curtime_odt;
|
||||||
|
let issued_by = IdentityId::User(tuuid);
|
||||||
|
let scope = AccessScope::IdentityOnly;
|
||||||
|
|
||||||
|
// Mod the user
|
||||||
|
let modlist = modlist!([
|
||||||
|
Modify::Present(
|
||||||
|
"oauth2_session".into(),
|
||||||
|
Value::Oauth2Session(
|
||||||
|
session_id,
|
||||||
|
Oauth2Session {
|
||||||
|
parent,
|
||||||
|
// Note we set the exp to None so we are not removing based on exp
|
||||||
|
expiry: None,
|
||||||
|
issued_at,
|
||||||
|
rs_uuid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Modify::Present(
|
||||||
|
"user_auth_token_session".into(),
|
||||||
|
Value::Session(
|
||||||
|
parent,
|
||||||
|
Session {
|
||||||
|
label: "label".to_string(),
|
||||||
|
// Note we set the exp to None so we are not removing based on removal of the parent.
|
||||||
|
expiry: None,
|
||||||
|
// Need the other inner bits?
|
||||||
|
// for the gracewindow.
|
||||||
|
issued_at,
|
||||||
|
// Who actually created this?
|
||||||
|
issued_by,
|
||||||
|
// What is the access scope of this session? This is
|
||||||
|
// for auditing purposes.
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Still there
|
||||||
|
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
assert!(entry.attribute_equality("user_auth_token_session", &pv_parent_id));
|
||||||
|
assert!(entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
// We need the time to be past grace_window.
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
let mut server_txn = server.write(exp_curtime).await;
|
||||||
|
|
||||||
|
// Mod again - remove the parent session.
|
||||||
|
let modlist = ModifyList::new_remove("user_auth_token_session", pv_parent_id.clone());
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Session gone.
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
// Note the uat is removed
|
||||||
|
assert!(!entry.attribute_equality("user_auth_token_session", &pv_parent_id));
|
||||||
|
// The oauth2 session is also removed.
|
||||||
|
assert!(!entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if an oauth2 session exists, the grace window passes and it's UAT doesn't exist.
|
||||||
|
#[qs_test]
|
||||||
|
async fn test_session_consistency_oauth2_grace_window_past(server: &QueryServer) {
|
||||||
|
let curtime = duration_from_epoch_now();
|
||||||
|
let curtime_odt = OffsetDateTime::unix_epoch() + curtime;
|
||||||
|
|
||||||
|
// Set exp to gracewindow.
|
||||||
|
let exp_curtime = curtime + GRACE_WINDOW;
|
||||||
|
// let exp_curtime_odt = OffsetDateTime::unix_epoch() + exp_curtime;
|
||||||
|
|
||||||
|
// Create a user
|
||||||
|
let mut server_txn = server.write(curtime).await;
|
||||||
|
|
||||||
|
let tuuid = uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930");
|
||||||
|
let rs_uuid = Uuid::new_v4();
|
||||||
|
|
||||||
|
let e1 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("person")),
|
||||||
|
("class", Value::new_class("account")),
|
||||||
|
("name", Value::new_iname("testperson1")),
|
||||||
|
("uuid", Value::new_uuid(tuuid)),
|
||||||
|
("description", Value::new_utf8s("testperson1")),
|
||||||
|
("displayname", Value::new_utf8s("testperson1"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let e2 = entry_init!(
|
||||||
|
("class", Value::new_class("object")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server")),
|
||||||
|
("class", Value::new_class("oauth2_resource_server_basic")),
|
||||||
|
("uuid", Value::new_uuid(rs_uuid)),
|
||||||
|
("oauth2_rs_name", Value::new_iname("test_resource_server")),
|
||||||
|
("displayname", Value::new_utf8s("test_resource_server")),
|
||||||
|
(
|
||||||
|
"oauth2_rs_origin",
|
||||||
|
Value::new_url_s("https://demo.example.com").unwrap()
|
||||||
|
),
|
||||||
|
// System admins
|
||||||
|
(
|
||||||
|
"oauth2_rs_scope_map",
|
||||||
|
Value::new_oauthscopemap(UUID_IDM_ALL_ACCOUNTS, btreeset!["openid".to_string()])
|
||||||
|
.expect("invalid oauthscope")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let ce = CreateEvent::new_internal(vec![e1, e2]);
|
||||||
|
assert!(server_txn.create(&ce).is_ok());
|
||||||
|
|
||||||
|
// Create a fake session.
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let pv_session_id = PartialValue::new_refer(session_id);
|
||||||
|
|
||||||
|
let parent = Uuid::new_v4();
|
||||||
|
let issued_at = curtime_odt;
|
||||||
|
|
||||||
|
let session = Value::Oauth2Session(
|
||||||
|
session_id,
|
||||||
|
Oauth2Session {
|
||||||
|
parent,
|
||||||
|
// Note we set the exp to None so we are asserting the removal is due to the lack
|
||||||
|
// of the parent session.
|
||||||
|
expiry: None,
|
||||||
|
issued_at,
|
||||||
|
rs_uuid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mod the user
|
||||||
|
let modlist = ModifyList::new_append("oauth2_session", session);
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Still there
|
||||||
|
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
assert!(entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
|
||||||
|
// Note the exp_curtime now is past the gracewindow. This will trigger
|
||||||
|
// consistency to purge the un-matched session.
|
||||||
|
let mut server_txn = server.write(exp_curtime).await;
|
||||||
|
|
||||||
|
// Mod again - anything will do.
|
||||||
|
let modlist =
|
||||||
|
ModifyList::new_purge_and_set("description", Value::new_utf8s("test person 1 change"));
|
||||||
|
|
||||||
|
server_txn
|
||||||
|
.internal_modify(
|
||||||
|
&filter!(f_eq("uuid", PartialValue::new_uuid(tuuid))),
|
||||||
|
&modlist,
|
||||||
|
)
|
||||||
|
.expect("Failed to modify user");
|
||||||
|
|
||||||
|
// Session gone.
|
||||||
|
let entry = server_txn.internal_search_uuid(&tuuid).expect("failed");
|
||||||
|
|
||||||
|
// Note it's a not condition now.
|
||||||
|
assert!(!entry.attribute_equality("oauth2_session", &pv_session_id));
|
||||||
|
|
||||||
|
assert!(server_txn.commit().is_ok());
|
||||||
|
}
|
||||||
|
}
|
|
@ -193,6 +193,7 @@ impl SchemaAttribute {
|
||||||
SyntaxType::DeviceKey => matches!(v, PartialValue::DeviceKey(_)),
|
SyntaxType::DeviceKey => matches!(v, PartialValue::DeviceKey(_)),
|
||||||
// Allow refer types.
|
// Allow refer types.
|
||||||
SyntaxType::Session => matches!(v, PartialValue::Refer(_)),
|
SyntaxType::Session => matches!(v, PartialValue::Refer(_)),
|
||||||
|
SyntaxType::Oauth2Session => matches!(v, PartialValue::Refer(_)),
|
||||||
// These are just insensitive string lookups on the hex-ified kid.
|
// These are just insensitive string lookups on the hex-ified kid.
|
||||||
SyntaxType::JwsKeyEs256 => matches!(v, PartialValue::Iutf8(_)),
|
SyntaxType::JwsKeyEs256 => matches!(v, PartialValue::Iutf8(_)),
|
||||||
SyntaxType::JwsKeyRs256 => matches!(v, PartialValue::Iutf8(_)),
|
SyntaxType::JwsKeyRs256 => matches!(v, PartialValue::Iutf8(_)),
|
||||||
|
@ -239,6 +240,7 @@ impl SchemaAttribute {
|
||||||
SyntaxType::Passkey => matches!(v, Value::Passkey(_, _, _)),
|
SyntaxType::Passkey => matches!(v, Value::Passkey(_, _, _)),
|
||||||
SyntaxType::DeviceKey => matches!(v, Value::DeviceKey(_, _, _)),
|
SyntaxType::DeviceKey => matches!(v, Value::DeviceKey(_, _, _)),
|
||||||
SyntaxType::Session => matches!(v, Value::Session(_, _)),
|
SyntaxType::Session => matches!(v, Value::Session(_, _)),
|
||||||
|
SyntaxType::Oauth2Session => matches!(v, Value::Oauth2Session(_, _)),
|
||||||
SyntaxType::JwsKeyEs256 => matches!(v, Value::JwsKeyEs256(_)),
|
SyntaxType::JwsKeyEs256 => matches!(v, Value::JwsKeyEs256(_)),
|
||||||
SyntaxType::JwsKeyRs256 => matches!(v, Value::JwsKeyRs256(_)),
|
SyntaxType::JwsKeyRs256 => matches!(v, Value::JwsKeyRs256(_)),
|
||||||
};
|
};
|
||||||
|
@ -582,7 +584,10 @@ impl<'a> SchemaWriteTransaction<'a> {
|
||||||
// No, they'll over-write each other ... but we do need name uniqueness.
|
// No, they'll over-write each other ... but we do need name uniqueness.
|
||||||
attributetypes.into_iter().for_each(|a| {
|
attributetypes.into_iter().for_each(|a| {
|
||||||
// Update the unique and ref caches.
|
// Update the unique and ref caches.
|
||||||
if a.syntax == SyntaxType::ReferenceUuid || a.syntax == SyntaxType::OauthScopeMap
|
if a.syntax == SyntaxType::ReferenceUuid ||
|
||||||
|
a.syntax == SyntaxType::OauthScopeMap ||
|
||||||
|
// 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?
|
// May not need to be a ref type since it doesn't have external links/impact?
|
||||||
// || a.syntax == SyntaxType::Session
|
// || a.syntax == SyntaxType::Session
|
||||||
{
|
{
|
||||||
|
|
|
@ -90,6 +90,7 @@ pub struct QueryServerWriteTransaction<'a> {
|
||||||
committed: bool,
|
committed: bool,
|
||||||
phase: CowCellWriteTxn<'a, ServerPhase>,
|
phase: CowCellWriteTxn<'a, ServerPhase>,
|
||||||
d_info: CowCellWriteTxn<'a, DomainInfo>,
|
d_info: CowCellWriteTxn<'a, DomainInfo>,
|
||||||
|
curtime: Duration,
|
||||||
cid: Cid,
|
cid: Cid,
|
||||||
be_txn: BackendWriteTransaction<'a>,
|
be_txn: BackendWriteTransaction<'a>,
|
||||||
schema: SchemaWriteTransaction<'a>,
|
schema: SchemaWriteTransaction<'a>,
|
||||||
|
@ -515,6 +516,7 @@ pub trait QueryServerTransaction<'a> {
|
||||||
SyntaxType::Session => Err(OperationError::InvalidAttribute("Session Values can not be supplied through modification".to_string())),
|
SyntaxType::Session => Err(OperationError::InvalidAttribute("Session Values can not be supplied through modification".to_string())),
|
||||||
SyntaxType::JwsKeyEs256 => Err(OperationError::InvalidAttribute("JwsKeyEs256 Values can not be supplied through modification".to_string())),
|
SyntaxType::JwsKeyEs256 => Err(OperationError::InvalidAttribute("JwsKeyEs256 Values can not be supplied through modification".to_string())),
|
||||||
SyntaxType::JwsKeyRs256 => Err(OperationError::InvalidAttribute("JwsKeyRs256 Values can not be supplied through modification".to_string())),
|
SyntaxType::JwsKeyRs256 => Err(OperationError::InvalidAttribute("JwsKeyRs256 Values can not be supplied through modification".to_string())),
|
||||||
|
SyntaxType::Oauth2Session => Err(OperationError::InvalidAttribute("Oauth2Session Values can not be supplied through modification".to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
@ -576,7 +578,10 @@ pub trait QueryServerTransaction<'a> {
|
||||||
// ⚠️ Any types here need to also be added to update_attributes in
|
// ⚠️ Any types here need to also be added to update_attributes in
|
||||||
// schema.rs for reference type / cache awareness during referential
|
// schema.rs for reference type / cache awareness during referential
|
||||||
// integrity processing. Exceptions are self-contained value types!
|
// integrity processing. Exceptions are self-contained value types!
|
||||||
SyntaxType::ReferenceUuid | SyntaxType::OauthScopeMap | SyntaxType::Session => {
|
SyntaxType::ReferenceUuid
|
||||||
|
| SyntaxType::OauthScopeMap
|
||||||
|
| SyntaxType::Session
|
||||||
|
| SyntaxType::Oauth2Session => {
|
||||||
// See comments above.
|
// See comments above.
|
||||||
PartialValue::new_refer_s(value)
|
PartialValue::new_refer_s(value)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
|
@ -1004,7 +1009,7 @@ impl QueryServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn write(&self, ts: Duration) -> QueryServerWriteTransaction<'_> {
|
pub async fn write(&self, curtime: Duration) -> QueryServerWriteTransaction<'_> {
|
||||||
// Guarantee we are the only writer on the thread pool
|
// Guarantee we are the only writer on the thread pool
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
let write_ticket = self
|
let write_ticket = self
|
||||||
|
@ -1026,8 +1031,10 @@ impl QueryServer {
|
||||||
let phase = self.phase.write();
|
let phase = self.phase.write();
|
||||||
|
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
let ts_max = be_txn.get_db_ts_max(ts).expect("Unable to get db_ts_max");
|
let ts_max = be_txn
|
||||||
let cid = Cid::new_lamport(self.s_uuid, d_info.d_uuid, ts, &ts_max);
|
.get_db_ts_max(curtime)
|
||||||
|
.expect("Unable to get db_ts_max");
|
||||||
|
let cid = Cid::new_lamport(self.s_uuid, d_info.d_uuid, curtime, &ts_max);
|
||||||
|
|
||||||
QueryServerWriteTransaction {
|
QueryServerWriteTransaction {
|
||||||
// I think this is *not* needed, because commit is mut self which should
|
// I think this is *not* needed, because commit is mut self which should
|
||||||
|
@ -1039,6 +1046,7 @@ impl QueryServer {
|
||||||
committed: false,
|
committed: false,
|
||||||
phase,
|
phase,
|
||||||
d_info,
|
d_info,
|
||||||
|
curtime,
|
||||||
cid,
|
cid,
|
||||||
be_txn,
|
be_txn,
|
||||||
schema: schema_write,
|
schema: schema_write,
|
||||||
|
@ -1174,6 +1182,10 @@ impl QueryServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> QueryServerWriteTransaction<'a> {
|
impl<'a> QueryServerWriteTransaction<'a> {
|
||||||
|
pub(crate) fn get_curtime(&self) -> Duration {
|
||||||
|
self.curtime
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(level = "debug", skip_all)]
|
#[instrument(level = "debug", skip_all)]
|
||||||
pub fn create(&mut self, ce: &CreateEvent) -> Result<(), OperationError> {
|
pub fn create(&mut self, ce: &CreateEvent) -> Result<(), OperationError> {
|
||||||
// The create event is a raw, read only representation of the request
|
// The create event is a raw, read only representation of the request
|
||||||
|
@ -2371,6 +2383,15 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
||||||
self.delete(&de)
|
self.delete(&de)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn internal_delete_uuid(&mut self, target_uuid: Uuid) -> Result<(), OperationError> {
|
||||||
|
let filter = filter!(f_eq("uuid", PartialValue::new_uuid(target_uuid)));
|
||||||
|
let f_valid = filter
|
||||||
|
.validate(self.get_schema())
|
||||||
|
.map_err(OperationError::SchemaViolation)?;
|
||||||
|
let de = DeleteEvent::new_internal(f_valid);
|
||||||
|
self.delete(&de)
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(level = "debug", skip_all)]
|
#[instrument(level = "debug", skip_all)]
|
||||||
pub fn internal_modify(
|
pub fn internal_modify(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
@ -2653,6 +2674,7 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
||||||
JSON_SCHEMA_ATTR_API_TOKEN_SESSION,
|
JSON_SCHEMA_ATTR_API_TOKEN_SESSION,
|
||||||
JSON_SCHEMA_ATTR_OAUTH2_RS_SUP_SCOPE_MAP,
|
JSON_SCHEMA_ATTR_OAUTH2_RS_SUP_SCOPE_MAP,
|
||||||
JSON_SCHEMA_ATTR_USER_AUTH_TOKEN_SESSION,
|
JSON_SCHEMA_ATTR_USER_AUTH_TOKEN_SESSION,
|
||||||
|
JSON_SCHEMA_ATTR_OAUTH2_SESSION,
|
||||||
JSON_SCHEMA_ATTR_NSUNIQUEID,
|
JSON_SCHEMA_ATTR_NSUNIQUEID,
|
||||||
JSON_SCHEMA_ATTR_OAUTH2_PREFER_SHORT_USERNAME,
|
JSON_SCHEMA_ATTR_OAUTH2_PREFER_SHORT_USERNAME,
|
||||||
JSON_SCHEMA_ATTR_SYNC_TOKEN_SESSION,
|
JSON_SCHEMA_ATTR_SYNC_TOKEN_SESSION,
|
||||||
|
|
|
@ -188,6 +188,7 @@ pub enum SyntaxType {
|
||||||
Session = 25,
|
Session = 25,
|
||||||
JwsKeyEs256 = 26,
|
JwsKeyEs256 = 26,
|
||||||
JwsKeyRs256 = 27,
|
JwsKeyRs256 = 27,
|
||||||
|
Oauth2Session = 28,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&str> for SyntaxType {
|
impl TryFrom<&str> for SyntaxType {
|
||||||
|
@ -225,6 +226,7 @@ impl TryFrom<&str> for SyntaxType {
|
||||||
"SESSION" => Ok(SyntaxType::Session),
|
"SESSION" => Ok(SyntaxType::Session),
|
||||||
"JWS_KEY_ES256" => Ok(SyntaxType::JwsKeyEs256),
|
"JWS_KEY_ES256" => Ok(SyntaxType::JwsKeyEs256),
|
||||||
"JWS_KEY_RS256" => Ok(SyntaxType::JwsKeyRs256),
|
"JWS_KEY_RS256" => Ok(SyntaxType::JwsKeyRs256),
|
||||||
|
"OAUTH2SESSION" => Ok(SyntaxType::Oauth2Session),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -263,6 +265,7 @@ impl TryFrom<u16> for SyntaxType {
|
||||||
25 => Ok(SyntaxType::Session),
|
25 => Ok(SyntaxType::Session),
|
||||||
26 => Ok(SyntaxType::JwsKeyEs256),
|
26 => Ok(SyntaxType::JwsKeyEs256),
|
||||||
27 => Ok(SyntaxType::JwsKeyRs256),
|
27 => Ok(SyntaxType::JwsKeyRs256),
|
||||||
|
28 => Ok(SyntaxType::Oauth2Session),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -299,6 +302,7 @@ impl fmt::Display for SyntaxType {
|
||||||
SyntaxType::Session => "SESSION",
|
SyntaxType::Session => "SESSION",
|
||||||
SyntaxType::JwsKeyEs256 => "JWS_KEY_ES256",
|
SyntaxType::JwsKeyEs256 => "JWS_KEY_ES256",
|
||||||
SyntaxType::JwsKeyRs256 => "JWS_KEY_RS256",
|
SyntaxType::JwsKeyRs256 => "JWS_KEY_RS256",
|
||||||
|
SyntaxType::Oauth2Session => "OAUTH2SESSION",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -750,6 +754,14 @@ pub struct Session {
|
||||||
pub scope: AccessScope,
|
pub scope: AccessScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Oauth2Session {
|
||||||
|
pub parent: Uuid,
|
||||||
|
pub expiry: Option<OffsetDateTime>,
|
||||||
|
pub issued_at: OffsetDateTime,
|
||||||
|
pub rs_uuid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
/// A value is a complete unit of data for an attribute. It is made up of a PartialValue, which is
|
/// A value is a complete unit of data for an attribute. It is made up of a PartialValue, which is
|
||||||
/// used for selection, filtering, searching, matching etc. It also contains supplemental data
|
/// used for selection, filtering, searching, matching etc. It also contains supplemental data
|
||||||
/// which may be stored inside of the Value, such as credential secrets, blobs etc.
|
/// which may be stored inside of the Value, such as credential secrets, blobs etc.
|
||||||
|
@ -794,6 +806,7 @@ pub enum Value {
|
||||||
|
|
||||||
TrustedDeviceEnrollment(Uuid),
|
TrustedDeviceEnrollment(Uuid),
|
||||||
Session(Uuid, Session),
|
Session(Uuid, Session),
|
||||||
|
Oauth2Session(Uuid, Oauth2Session),
|
||||||
|
|
||||||
JwsKeyEs256(JwsSigner),
|
JwsKeyEs256(JwsSigner),
|
||||||
JwsKeyRs256(JwsSigner),
|
JwsKeyRs256(JwsSigner),
|
||||||
|
@ -1329,10 +1342,12 @@ impl Value {
|
||||||
|
|
||||||
// We need a seperate to-ref_uuid to distinguish from normal uuids
|
// We need a seperate to-ref_uuid to distinguish from normal uuids
|
||||||
// in refint plugin.
|
// in refint plugin.
|
||||||
pub fn to_ref_uuid(&self) -> Option<&Uuid> {
|
pub fn to_ref_uuid(&self) -> Option<Uuid> {
|
||||||
match &self {
|
match &self {
|
||||||
Value::Refer(u) => Some(u),
|
Value::Refer(u) => Some(*u),
|
||||||
Value::OauthScopeMap(u, _) => Some(u),
|
Value::OauthScopeMap(u, _) => Some(*u),
|
||||||
|
// We need to assert that our reference to our rs exists.
|
||||||
|
Value::Oauth2Session(_, m) => Some(m.rs_uuid),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ use crate::credential::Credential;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::repl::cid::Cid;
|
use crate::repl::cid::Cid;
|
||||||
use crate::schema::SchemaAttribute;
|
use crate::schema::SchemaAttribute;
|
||||||
use crate::value::{Address, IntentTokenState, Session};
|
use crate::value::{Address, IntentTokenState, Oauth2Session, Session};
|
||||||
|
|
||||||
mod address;
|
mod address;
|
||||||
mod binary;
|
mod binary;
|
||||||
|
@ -56,7 +56,7 @@ pub use self::nsuniqueid::ValueSetNsUniqueId;
|
||||||
pub use self::oauth::{ValueSetOauthScope, ValueSetOauthScopeMap};
|
pub use self::oauth::{ValueSetOauthScope, ValueSetOauthScopeMap};
|
||||||
pub use self::restricted::ValueSetRestricted;
|
pub use self::restricted::ValueSetRestricted;
|
||||||
pub use self::secret::ValueSetSecret;
|
pub use self::secret::ValueSetSecret;
|
||||||
pub use self::session::ValueSetSession;
|
pub use self::session::{ValueSetOauth2Session, ValueSetSession};
|
||||||
pub use self::spn::ValueSetSpn;
|
pub use self::spn::ValueSetSpn;
|
||||||
pub use self::ssh::ValueSetSshKey;
|
pub use self::ssh::ValueSetSshKey;
|
||||||
pub use self::syntax::ValueSetSyntax;
|
pub use self::syntax::ValueSetSyntax;
|
||||||
|
@ -471,6 +471,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn as_oauth2session_map(&self) -> Option<&BTreeMap<Uuid, Oauth2Session>> {
|
||||||
|
debug_assert!(false);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn to_jws_key_es256_single(&self) -> Option<&JwsSigner> {
|
fn to_jws_key_es256_single(&self) -> Option<&JwsSigner> {
|
||||||
debug_assert!(false);
|
debug_assert!(false);
|
||||||
None
|
None
|
||||||
|
@ -546,6 +551,7 @@ pub fn from_result_value_iter(
|
||||||
| Value::DeviceKey(_, _, _)
|
| Value::DeviceKey(_, _, _)
|
||||||
| Value::TrustedDeviceEnrollment(_)
|
| Value::TrustedDeviceEnrollment(_)
|
||||||
| Value::Session(_, _)
|
| Value::Session(_, _)
|
||||||
|
| Value::Oauth2Session(_, _)
|
||||||
| Value::JwsKeyEs256(_)
|
| Value::JwsKeyEs256(_)
|
||||||
| Value::JwsKeyRs256(_) => {
|
| Value::JwsKeyRs256(_) => {
|
||||||
debug_assert!(false);
|
debug_assert!(false);
|
||||||
|
@ -601,6 +607,7 @@ pub fn from_value_iter(mut iter: impl Iterator<Item = Value>) -> Result<ValueSet
|
||||||
Value::JwsKeyEs256(k) => ValueSetJwsKeyEs256::new(k),
|
Value::JwsKeyEs256(k) => ValueSetJwsKeyEs256::new(k),
|
||||||
Value::JwsKeyRs256(k) => ValueSetJwsKeyRs256::new(k),
|
Value::JwsKeyRs256(k) => ValueSetJwsKeyRs256::new(k),
|
||||||
Value::Session(u, m) => ValueSetSession::new(u, m),
|
Value::Session(u, m) => ValueSetSession::new(u, m),
|
||||||
|
Value::Oauth2Session(u, m) => ValueSetOauth2Session::new(u, m),
|
||||||
Value::PhoneNumber(_, _) | Value::TrustedDeviceEnrollment(_) => {
|
Value::PhoneNumber(_, _) | Value::TrustedDeviceEnrollment(_) => {
|
||||||
debug_assert!(false);
|
debug_assert!(false);
|
||||||
return Err(OperationError::InvalidValueState);
|
return Err(OperationError::InvalidValueState);
|
||||||
|
@ -644,6 +651,7 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result<ValueSet, OperationErro
|
||||||
DbValueSetV2::Passkey(set) => ValueSetPasskey::from_dbvs2(set),
|
DbValueSetV2::Passkey(set) => ValueSetPasskey::from_dbvs2(set),
|
||||||
DbValueSetV2::DeviceKey(set) => ValueSetDeviceKey::from_dbvs2(set),
|
DbValueSetV2::DeviceKey(set) => ValueSetDeviceKey::from_dbvs2(set),
|
||||||
DbValueSetV2::Session(set) => ValueSetSession::from_dbvs2(set),
|
DbValueSetV2::Session(set) => ValueSetSession::from_dbvs2(set),
|
||||||
|
DbValueSetV2::Oauth2Session(set) => ValueSetOauth2Session::from_dbvs2(set),
|
||||||
DbValueSetV2::JwsKeyEs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set),
|
DbValueSetV2::JwsKeyEs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set),
|
||||||
DbValueSetV2::JwsKeyRs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set),
|
DbValueSetV2::JwsKeyRs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set),
|
||||||
DbValueSetV2::PhoneNumber(_, _) | DbValueSetV2::TrustedDeviceEnrollment(_) => {
|
DbValueSetV2::PhoneNumber(_, _) | DbValueSetV2::TrustedDeviceEnrollment(_) => {
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
use std::collections::btree_map::Entry as BTreeEntry;
|
use std::collections::btree_map::Entry as BTreeEntry;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::be::dbvalue::{DbValueAccessScopeV1, DbValueIdentityId, DbValueSession};
|
use crate::be::dbvalue::{
|
||||||
|
DbValueAccessScopeV1, DbValueIdentityId, DbValueOauth2Session, DbValueSession,
|
||||||
|
};
|
||||||
use crate::identity::{AccessScope, IdentityId};
|
use crate::identity::{AccessScope, IdentityId};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::schema::SchemaAttribute;
|
use crate::schema::SchemaAttribute;
|
||||||
use crate::value::Session;
|
use crate::value::{Oauth2Session, Session};
|
||||||
use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ValueSet};
|
use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ValueSet};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -250,3 +252,276 @@ impl ValueSetT for ValueSetSession {
|
||||||
Some(Box::new(self.map.keys().copied()))
|
Some(Box::new(self.map.keys().copied()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// == oauth2 session ==
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ValueSetOauth2Session {
|
||||||
|
map: BTreeMap<Uuid, Oauth2Session>,
|
||||||
|
// this is a "filter" to tell us if as rs_id is used anywhere
|
||||||
|
// in this set. The reason is so that we don't do O(n) searches
|
||||||
|
// on a refer if it's not in this set. The alternate approach is
|
||||||
|
// an index on these maps, but its more work to mantain for a rare
|
||||||
|
// situation where we actually want to query rs_uuid -> sessions.
|
||||||
|
rs_filter: BTreeSet<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValueSetOauth2Session {
|
||||||
|
pub fn new(u: Uuid, m: Oauth2Session) -> Box<Self> {
|
||||||
|
let mut map = BTreeMap::new();
|
||||||
|
let mut rs_filter = BTreeSet::new();
|
||||||
|
rs_filter.insert(m.rs_uuid);
|
||||||
|
map.insert(u, m);
|
||||||
|
Box::new(ValueSetOauth2Session { map, rs_filter })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, u: Uuid, m: Oauth2Session) -> bool {
|
||||||
|
self.rs_filter.insert(m.rs_uuid);
|
||||||
|
self.map.insert(u, m).is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_dbvs2(data: Vec<DbValueOauth2Session>) -> Result<ValueSet, OperationError> {
|
||||||
|
let mut rs_filter = BTreeSet::new();
|
||||||
|
let map = data
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|dbv| {
|
||||||
|
match dbv {
|
||||||
|
DbValueOauth2Session::V1 {
|
||||||
|
refer,
|
||||||
|
parent,
|
||||||
|
expiry,
|
||||||
|
issued_at,
|
||||||
|
rs_uuid,
|
||||||
|
} => {
|
||||||
|
// Convert things.
|
||||||
|
let issued_at = OffsetDateTime::parse(issued_at, time::Format::Rfc3339)
|
||||||
|
.map(|odt| odt.to_offset(time::UtcOffset::UTC))
|
||||||
|
.map_err(|e| {
|
||||||
|
admin_error!(
|
||||||
|
?e,
|
||||||
|
"Invalidating session {} due to invalid issued_at timestamp",
|
||||||
|
refer
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// This is a bit annoying. In the case we can't parse the optional
|
||||||
|
// expiry, we need to NOT return the session so that it's immediately
|
||||||
|
// invalidated. To do this we have to invert some of the options involved
|
||||||
|
// here.
|
||||||
|
let expiry = expiry
|
||||||
|
.map(|e_inner| {
|
||||||
|
OffsetDateTime::parse(e_inner, time::Format::Rfc3339)
|
||||||
|
.map(|odt| odt.to_offset(time::UtcOffset::UTC))
|
||||||
|
// We now have an
|
||||||
|
// Option<Result<ODT, _>>
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
// Result<Option<ODT>, _>
|
||||||
|
.map_err(|e| {
|
||||||
|
admin_error!(
|
||||||
|
?e,
|
||||||
|
"Invalidating session {} due to invalid expiry timestamp",
|
||||||
|
refer
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// Option<Option<ODT>>
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// Insert to the rs_filter.
|
||||||
|
rs_filter.insert(rs_uuid);
|
||||||
|
Some((
|
||||||
|
refer,
|
||||||
|
Oauth2Session {
|
||||||
|
parent,
|
||||||
|
expiry,
|
||||||
|
issued_at,
|
||||||
|
rs_uuid,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Box::new(ValueSetOauth2Session { map, rs_filter }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 fn from_iter<T>(iter: T) -> Option<Box<Self>>
|
||||||
|
where
|
||||||
|
T: IntoIterator<Item = (Uuid, Oauth2Session)>,
|
||||||
|
{
|
||||||
|
let mut rs_filter = BTreeSet::new();
|
||||||
|
let map = iter
|
||||||
|
.into_iter()
|
||||||
|
.map(|(u, m)| {
|
||||||
|
rs_filter.insert(m.rs_uuid);
|
||||||
|
(u, m)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(Box::new(ValueSetOauth2Session { map, rs_filter }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValueSetT for ValueSetOauth2Session {
|
||||||
|
fn insert_checked(&mut self, value: Value) -> Result<bool, OperationError> {
|
||||||
|
match value {
|
||||||
|
Value::Oauth2Session(u, m) => {
|
||||||
|
if let BTreeEntry::Vacant(e) = self.map.entry(u) {
|
||||||
|
self.rs_filter.insert(m.rs_uuid);
|
||||||
|
e.insert(m);
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Err(OperationError::InvalidValueState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear(&mut self) {
|
||||||
|
self.rs_filter.clear();
|
||||||
|
self.map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&mut self, pv: &PartialValue) -> bool {
|
||||||
|
match pv {
|
||||||
|
PartialValue::Refer(u) => {
|
||||||
|
let found = self.map.remove(u).is_some();
|
||||||
|
if !found {
|
||||||
|
// Perhaps the reference id is an rs_uuid?
|
||||||
|
if self.rs_filter.contains(u) {
|
||||||
|
// It's there, so we need to do a more costly retain operation over the values.
|
||||||
|
self.map.retain(|_, m| m.rs_uuid != *u);
|
||||||
|
self.rs_filter.remove(u);
|
||||||
|
// We removed something, so yeeeet.
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
// It's not in the rs_filter or the map, false.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// We found it in the map, true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains(&self, pv: &PartialValue) -> bool {
|
||||||
|
match pv {
|
||||||
|
PartialValue::Refer(u) => self.map.contains_key(u) || self.rs_filter.contains(u),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn substring(&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()
|
||||||
|
.map(|u| u.as_hyphenated().to_string())
|
||||||
|
// We also refer to our rs_uuid's.
|
||||||
|
.chain(self.rs_filter.iter().map(|u| u.as_hyphenated().to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn syntax(&self) -> SyntaxType {
|
||||||
|
SyntaxType::Oauth2Session
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self, _schema_attr: &SchemaAttribute) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_proto_string_clone_iter(&self) -> Box<dyn Iterator<Item = String> + '_> {
|
||||||
|
Box::new(
|
||||||
|
self.map
|
||||||
|
.iter()
|
||||||
|
.map(|(u, m)| format!("{}: {:?}", uuid_to_proto_string(*u), m)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_db_valueset_v2(&self) -> DbValueSetV2 {
|
||||||
|
DbValueSetV2::Oauth2Session(
|
||||||
|
self.map
|
||||||
|
.iter()
|
||||||
|
.map(|(u, m)| DbValueOauth2Session::V1 {
|
||||||
|
refer: *u,
|
||||||
|
parent: m.parent,
|
||||||
|
expiry: m.expiry.map(|odt| {
|
||||||
|
debug_assert!(odt.offset() == time::UtcOffset::UTC);
|
||||||
|
odt.format(time::Format::Rfc3339)
|
||||||
|
}),
|
||||||
|
issued_at: {
|
||||||
|
debug_assert!(m.issued_at.offset() == time::UtcOffset::UTC);
|
||||||
|
m.issued_at.format(time::Format::Rfc3339)
|
||||||
|
},
|
||||||
|
rs_uuid: m.rs_uuid,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_partialvalue_iter(&self) -> Box<dyn Iterator<Item = PartialValue> + '_> {
|
||||||
|
Box::new(self.map.keys().cloned().map(PartialValue::Refer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value_iter(&self) -> Box<dyn Iterator<Item = Value> + '_> {
|
||||||
|
Box::new(
|
||||||
|
self.map
|
||||||
|
.iter()
|
||||||
|
.map(|(u, m)| Value::Oauth2Session(*u, m.clone())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn equal(&self, other: &ValueSet) -> bool {
|
||||||
|
if let Some(other) = other.as_oauth2session_map() {
|
||||||
|
&self.map == other
|
||||||
|
} else {
|
||||||
|
debug_assert!(false);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> {
|
||||||
|
if let Some(b) = other.as_oauth2session_map() {
|
||||||
|
// Merge the rs_filters.
|
||||||
|
// We have to do this without the mergemap macro so that rs_filter
|
||||||
|
// is updated.
|
||||||
|
b.iter().for_each(|(k, v)| {
|
||||||
|
if !self.map.contains_key(k) {
|
||||||
|
self.rs_filter.insert(v.rs_uuid);
|
||||||
|
self.map.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
debug_assert!(false);
|
||||||
|
Err(OperationError::InvalidValueState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_oauth2session_map(&self) -> Option<&BTreeMap<Uuid, Oauth2Session>> {
|
||||||
|
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. We need to
|
||||||
|
// bind to our resource servers, not our ids!
|
||||||
|
Some(Box::new(self.map.values().map(|m| &m.rs_uuid).copied()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue