From 06c9e087cbdd74c9116326f369c4e4633cfb938d Mon Sep 17 00:00:00 2001 From: Firstyear Date: Sun, 13 Nov 2022 14:10:45 +1000 Subject: [PATCH] 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 --- kanidm_book/src/integrations/oauth2.md | 22 +- kanidm_proto/src/oauth2.rs | 13 +- kanidmd/core/src/actors/v1_write.rs | 21 +- kanidmd/core/src/https/oauth2.rs | 73 +++- kanidmd/lib/src/be/dbvalue.rs | 19 + kanidmd/lib/src/constants/schema.rs | 34 +- kanidmd/lib/src/constants/uuids.rs | 1 + kanidmd/lib/src/entry.rs | 14 +- kanidmd/lib/src/idm/delayed.rs | 12 + kanidmd/lib/src/idm/oauth2.rs | 581 ++++++++++++++++++++++--- kanidmd/lib/src/idm/server.rs | 170 ++++---- kanidmd/lib/src/plugins/mod.rs | 2 + kanidmd/lib/src/plugins/refint.rs | 123 +++++- kanidmd/lib/src/plugins/session.rs | 575 ++++++++++++++++++++++++ kanidmd/lib/src/schema.rs | 7 +- kanidmd/lib/src/server.rs | 30 +- kanidmd/lib/src/value.rs | 21 +- kanidmd/lib/src/valueset/mod.rs | 12 +- kanidmd/lib/src/valueset/session.rs | 281 +++++++++++- 19 files changed, 1838 insertions(+), 173 deletions(-) create mode 100644 kanidmd/lib/src/plugins/session.rs diff --git a/kanidm_book/src/integrations/oauth2.md b/kanidm_book/src/integrations/oauth2.md index ab6b0044a..b71b78656 100644 --- a/kanidm_book/src/integrations/oauth2.md +++ b/kanidm_book/src/integrations/oauth2.md @@ -1,9 +1,9 @@ # OAuth2 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 -do not provide identity or authentication information, only information that -an entity is authorised for the requested resources. +OAuth only provides authorisation, as the protocol in its default forms +do not provide identity or authentication information. All that Oauth2 provides is +information that an entity is authorised for the requested resources. OAuth can tie into extensions allowing an identity provider to reveal information 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 * api auth url: https://idm.example.com/oauth2/authorise * 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 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 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 -that is granted to the account. +For an authorisation to proceed, all scopes requested by the resource server must be available in the +final scope set that is granted to the account. 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 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. @@ -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 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: kanidm system oauth2 warning_insecure_client_disable_pkce diff --git a/kanidm_proto/src/oauth2.rs b/kanidm_proto/src/oauth2.rs index a2c134356..0654860e4 100644 --- a/kanidm_proto/src/oauth2.rs +++ b/kanidm_proto/src/oauth2.rs @@ -94,11 +94,16 @@ pub struct AccessTokenRequest { pub code_verifier: Option, } -// 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: + /// + #[serde(skip_serializing_if = "Option::is_none")] + pub token_type_hint: Option, +} -// If and only if it checks out, we proceed. - -// Returned as a json body +// The corresponding Response to a revoke request is empty body with 200. #[derive(Serialize, Deserialize, Debug)] pub struct AccessTokenResponse { diff --git a/kanidmd/core/src/actors/v1_write.rs b/kanidmd/core/src/actors/v1_write.rs index 4f5758d2e..bc257bc5d 100644 --- a/kanidmd/core/src/actors/v1_write.rs +++ b/kanidmd/core/src/actors/v1_write.rs @@ -24,6 +24,7 @@ use kanidmd_lib::{ }, idm::delayed::DelayedAction, idm::event::{GeneratePasswordEvent, RegenerateRadiusSecretEvent, UnixPasswordChangeEvent}, + idm::oauth2::{Oauth2Error, TokenRevokeRequest}, idm::server::{IdmServer, IdmServerTransaction}, idm::serviceaccount::{DestroyApiTokenEvent, GenerateApiTokenEvent}, modify::{Modify, ModifyInvalid, ModifyList}, @@ -1351,8 +1352,8 @@ impl QueryServerWriteV1 { filter: Filter, eventid: Uuid, ) -> Result<(), OperationError> { - let mut idms_prox_write = self.idms.proxy_write(duration_from_epoch_now()).await; let ct = duration_from_epoch_now(); + let mut idms_prox_write = self.idms.proxy_write(ct).await; let ident = idms_prox_write .validate_and_parse_token_to_ident(uat.as_deref(), ct) @@ -1392,6 +1393,24 @@ impl QueryServerWriteV1 { .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. ===== #[instrument( level = "info", diff --git a/kanidmd/core/src/https/oauth2.rs b/kanidmd/core/src/https/oauth2.rs index f2696f877..e7348d13e 100644 --- a/kanidmd/core/src/https/oauth2.rs +++ b/kanidmd/core/src/https/oauth2.rs @@ -2,7 +2,7 @@ use kanidm_proto::oauth2::AuthorisationResponse; use kanidm_proto::v1::Entry as ProtoEntry; use kanidmd_lib::idm::oauth2::{ AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, - AuthoriseResponse, ErrorResponse, Oauth2Error, + AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest, }; use kanidmd_lib::prelude::*; use serde::{Deserialize, Serialize}; @@ -723,6 +723,68 @@ pub async fn oauth2_token_introspect_post(mut req: tide::Request) -> t }) } +pub async fn oauth2_token_revoke_post(mut req: tide::Request) -> 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) { let mut oauth2_process = appserver.at("/oauth2"); // ⚠️ ⚠️ WARNING ⚠️ ⚠️ @@ -738,36 +800,45 @@ pub fn oauth2_route_setup(appserver: &mut tide::Route<'_, AppState>, routemap: & .at("/authorise/permit") .mapped_post(routemap, oauth2_authorise_permit_post) .mapped_get(routemap, oauth2_authorise_permit_get); + // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS oauth2_process .at("/authorise/reject") .mapped_post(routemap, oauth2_authorise_reject_post) .mapped_get(routemap, oauth2_authorise_reject_get); + // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS oauth2_process .at("/token") .mapped_post(routemap, oauth2_token_post); + // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS oauth2_process .at("/token/introspect") .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"); // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS + openid_process .at("/:client_id/.well-known/openid-configuration") .mapped_get(routemap, oauth2_openid_discovery_get); // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS + openid_process .at("/:client_id/userinfo") .mapped_get(routemap, oauth2_openid_userinfo_get); // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS + openid_process .at("/:client_id/public_key.jwk") .mapped_get(routemap, oauth2_openid_publickey_get); diff --git a/kanidmd/lib/src/be/dbvalue.rs b/kanidmd/lib/src/be/dbvalue.rs index 271b1b5a5..6183e8d5d 100644 --- a/kanidmd/lib/src/be/dbvalue.rs +++ b/kanidmd/lib/src/be/dbvalue.rs @@ -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, + #[serde(rename = "i")] + issued_at: String, + #[serde(rename = "r")] + rs_uuid: Uuid, + }, +} + #[derive(Serialize, Deserialize, Debug)] pub enum DbValueV1 { #[serde(rename = "U8")] @@ -532,6 +548,8 @@ pub enum DbValueSetV2 { JwsKeyEs256(Vec>), #[serde(rename = "JR")] JwsKeyRs256(Vec>), + #[serde(rename = "AS")] + Oauth2Session(Vec), } impl DbValueSetV2 { @@ -568,6 +586,7 @@ impl DbValueSetV2 { DbValueSetV2::DeviceKey(set) => set.len(), DbValueSetV2::TrustedDeviceEnrollment(set) => set.len(), DbValueSetV2::Session(set) => set.len(), + DbValueSetV2::Oauth2Session(set) => set.len(), DbValueSetV2::JwsKeyEs256(set) => set.len(), DbValueSetV2::JwsKeyRs256(set) => set.len(), } diff --git a/kanidmd/lib/src/constants/schema.rs b/kanidmd/lib/src/constants/schema.rs index 7cef20b4b..acb1871d2 100644 --- a/kanidmd/lib/src/constants/schema.rs +++ b/kanidmd/lib/src/constants/schema.rs @@ -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#"{ "attrs": { "class": [ @@ -1371,7 +1402,8 @@ pub const JSON_SCHEMA_CLASS_ACCOUNT: &str = r#" "account_valid_from", "mail", "oauth2_consent_scope_map", - "user_auth_token_session" + "user_auth_token_session", + "oauth2_session" ], "systemmust": [ "displayname", diff --git a/kanidmd/lib/src/constants/uuids.rs b/kanidmd/lib/src/constants/uuids.rs index 7006854dd..945c1a8ae 100644 --- a/kanidmd/lib/src/constants/uuids.rs +++ b/kanidmd/lib/src/constants/uuids.rs @@ -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 = 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_OAUTH2_SESSION: Uuid = uuid!("00000000-0000-0000-0000-ffff00000117"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. diff --git a/kanidmd/lib/src/entry.rs b/kanidmd/lib/src/entry.rs index 39718a28a..5ca22d921 100644 --- a/kanidmd/lib/src/entry.rs +++ b/kanidmd/lib/src/entry.rs @@ -52,7 +52,9 @@ use crate::prelude::*; use crate::repl::cid::Cid; use crate::repl::entry::EntryChangelog; 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 std::convert::TryFrom; @@ -1863,6 +1865,16 @@ impl Entry { 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> { + self.attrs + .get(attr) + .and_then(|vs| vs.as_oauth2session_map()) + } + #[inline(always)] /// If possible, return an iterator over the set of values transformed into a `&str`. pub fn get_ava_iter_iname(&self, attr: &str) -> Option> { diff --git a/kanidmd/lib/src/idm/delayed.rs b/kanidmd/lib/src/idm/delayed.rs index 975860d79..118d1c3a8 100644 --- a/kanidmd/lib/src/idm/delayed.rs +++ b/kanidmd/lib/src/idm/delayed.rs @@ -13,6 +13,7 @@ pub enum DelayedAction { BackupCodeRemoval(BackupCodeRemoval), Oauth2ConsentGrant(Oauth2ConsentGrant), AuthSessionRecord(AuthSessionRecord), + Oauth2SessionRecord(Oauth2SessionRecord), } pub struct PasswordUpgrade { @@ -70,3 +71,14 @@ pub struct AuthSessionRecord { pub issued_by: IdentityId, pub scope: AccessScope, } + +#[derive(Debug)] +pub struct Oauth2SessionRecord { + pub target_uuid: Uuid, + pub parent_session_id: Uuid, + pub session_id: Uuid, + pub expiry: Option, + pub issued_at: OffsetDateTime, + // Which rs is this related to? + pub rs_uuid: Uuid, +} diff --git a/kanidmd/lib/src/idm/oauth2.rs b/kanidmd/lib/src/idm/oauth2.rs index 98795ab74..003194d29 100644 --- a/kanidmd/lib/src/idm/oauth2.rs +++ b/kanidmd/lib/src/idm/oauth2.rs @@ -19,7 +19,7 @@ use hashbrown::HashMap; pub use kanidm_proto::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, - OidcDiscoveryResponse, + OidcDiscoveryResponse, TokenRevokeRequest, }; use kanidm_proto::oauth2::{ ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode, ResponseType, SubjectType, @@ -34,8 +34,10 @@ use tracing::trace; use url::{Origin, Url}; use crate::identity::IdentityId; -use crate::idm::delayed::{DelayedAction, Oauth2ConsentGrant}; -use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerTransaction}; +use crate::idm::delayed::{DelayedAction, Oauth2ConsentGrant, Oauth2SessionRecord}; +use crate::idm::server::{ + IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction, IdmServerTransaction, +}; use crate::prelude::*; use crate::value::OAUTHSCOPE_RE; @@ -57,6 +59,8 @@ pub enum Oauth2Error { // from https://datatracker.ietf.org/doc/html/rfc6750 InvalidToken, InsufficientScope, + // from https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1 + UnsupportedTokenType, } impl std::fmt::Display for Oauth2Error { @@ -74,6 +78,7 @@ impl std::fmt::Display for Oauth2Error { Oauth2Error::TemporarilyUnavailable => "temporarily_unavailable", Oauth2Error::InvalidToken => "invalid_token", Oauth2Error::InsufficientScope => "insufficient_scope", + Oauth2Error::UnsupportedTokenType => "unsupported_token_type", }) } } @@ -116,31 +121,38 @@ struct TokenExchangeCode { #[derive(Serialize, Deserialize, Debug)] enum Oauth2TokenType { - Access(Oauth2AccessToken), - Refresh, + Access { + scopes: Vec, + parent_session_id: Uuid, + session_id: Uuid, + auth_type: AuthType, + expiry: time::OffsetDateTime, + uuid: Uuid, + iat: i64, + nbf: i64, + auth_time: Option, + }, + Refresh { + parent_session_id: Uuid, + session_id: Uuid, + expiry: time::OffsetDateTime, + uuid: Uuid, + }, } impl fmt::Display for Oauth2TokenType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Oauth2TokenType::Access(_) => write!(f, "access_token"), - Oauth2TokenType::Refresh => write!(f, "refresh_token"), + Oauth2TokenType::Access { session_id, .. } => { + 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, - 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, -} - #[derive(Debug)] pub enum AuthoriseResponse { 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 { pub fn check_oauth2_authorisation( &self, @@ -853,15 +951,8 @@ impl Oauth2ResourceServersReadTransaction { client_authz: Option<&str>, token_req: &AccessTokenRequest, ct: Duration, + async_tx: &Sender, ) -> Result { - // TODO: add refresh token grant type. - // If it's a refresh token grant, are the consent permissions the same? - - if token_req.grant_type != "authorization_code" { - admin_warn!("Invalid oauth2 grant_type (should be 'authorization_code')"); - return Err(Oauth2Error::InvalidRequest); - } - let (client_id, secret) = if let Some(client_authz) = client_authz { parse_basic_authz(client_authz)? } else { @@ -887,8 +978,26 @@ impl Oauth2ResourceServersReadTransaction { security_info!("Invalid oauth2 client_id secret"); return Err(Oauth2Error::AuthenticationRequired); } + // 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, + ) -> Result { // Check the token_req is within the valid time, and correctly signed for // this client. @@ -1023,7 +1132,7 @@ impl Oauth2ResourceServersReadTransaction { let oidc = OidcToken { iss, sub: OidcSubject::U(code_xchg.uat.uuid), - aud: client_id.clone(), + aud: o2rs.name.clone(), iat, nbf: Some(iat), exp, @@ -1032,7 +1141,7 @@ impl Oauth2ResourceServersReadTransaction { at_hash: None, acr: None, amr, - azp: Some(client_id.clone()), + azp: Some(o2rs.name.clone()), jti: None, s_claims: OidcClaims { // Map from displayname @@ -1061,17 +1170,22 @@ impl Oauth2ResourceServersReadTransaction { None }; - // TODO: Refresh tokens! - let access_token_raw = Oauth2TokenType::Access(Oauth2AccessToken { + let session_id = Uuid::new_v4(); + 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, - session_id: code_xchg.uat.session_id, + parent_session_id, + session_id, auth_type: code_xchg.uat.auth_type, expiry, uuid: code_xchg.uat.uuid, iat, nbf: iat, auth_time: None, - }); + }; let access_token_data = serde_json::to_vec(&access_token_raw).map_err(|e| { admin_error!(err = ?e, "Unable to encode consent data"); @@ -1084,6 +1198,20 @@ impl Oauth2ResourceServersReadTransaction { 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 { access_token, token_type: "bearer".to_string(), @@ -1125,40 +1253,53 @@ impl Oauth2ResourceServersReadTransaction { }) .and_then(|data| { 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 }) })?; 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? let odt_ct = OffsetDateTime::unix_epoch() + ct; - if at.expiry <= odt_ct { - security_info!(?at.uuid, "access token has expired, returning inactive"); + if expiry <= odt_ct { + security_info!(?uuid, "access token has expired, returning 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 - .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")); let account = match valid { 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()); } }; // ==== good to generate response ==== - let scope = if at.scopes.is_empty() { + let scope = if scopes.is_empty() { None } else { - Some(at.scopes.join(" ")) + Some(scopes.join(" ")) }; let token_type = Some("access_token".to_string()); @@ -1169,15 +1310,15 @@ impl Oauth2ResourceServersReadTransaction { username: Some(account.spn), token_type, exp: Some(exp), - iat: Some(at.iat), - nbf: Some(at.nbf), - sub: Some(at.uuid.to_string()), + iat: Some(iat), + nbf: Some(nbf), + sub: Some(uuid.to_string()), aud: Some(client_id), iss: None, jti: None, }) } - Oauth2TokenType::Refresh => Ok(AccessTokenIntrospectResponse::inactive()), + Oauth2TokenType::Refresh { .. } => Ok(AccessTokenIntrospectResponse::inactive()), } } @@ -1210,29 +1351,42 @@ impl Oauth2ResourceServersReadTransaction { })?; 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? let odt_ct = OffsetDateTime::unix_epoch() + ct; - if at.expiry <= odt_ct { - security_info!(?at.uuid, "access token has expired, returning inactive"); + if expiry <= odt_ct { + security_info!(?uuid, "access token has expired, returning inactive"); 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 - .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")); let account = match valid { 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); } }; - 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 { (Some(mp), Some(true)) } else { @@ -1242,7 +1396,7 @@ impl Oauth2ResourceServersReadTransaction { (None, None) }; - let amr = Some(vec![at.auth_type.to_string()]); + let amr = Some(vec![auth_type.to_string()]); let iss = o2rs.iss.clone(); @@ -1250,10 +1404,10 @@ impl Oauth2ResourceServersReadTransaction { Ok(OidcToken { iss, - sub: OidcSubject::U(at.uuid), + sub: OidcSubject::U(uuid), aud: client_id.to_string(), - iat: at.iat, - nbf: Some(at.nbf), + iat: iat, + nbf: Some(nbf), exp, auth_time: None, nonce: None, @@ -1267,7 +1421,7 @@ impl Oauth2ResourceServersReadTransaction { name: Some(account.displayname.clone()), // Map from spn preferred_username: Some(account.spn), - scopes: at.scopes, + scopes: scopes, email, email_verified, ..Default::default() @@ -1276,7 +1430,7 @@ impl Oauth2ResourceServersReadTransaction { }) } // 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) .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. assert!(token_response.token_type == "bearer"); } @@ -2109,6 +2269,12 @@ mod tests { .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) .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. let intr_request = AccessTokenIntrospectRequest { 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] fn test_idm_oauth2_authorisation_reject() { run_idm_test!(|_qs: &QueryServer, @@ -2439,6 +2886,12 @@ mod tests { .check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct) .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! assert!(token_response.token_type == "bearer"); @@ -2625,6 +3078,12 @@ mod tests { .check_oauth2_token_exchange(None, &token_req, ct) .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! assert!(token_response.token_type == "bearer"); let id_token = token_response.id_token.expect("No id_token in response!"); diff --git a/kanidmd/lib/src/idm/server.rs b/kanidmd/lib/src/idm/server.rs index 859e22b59..8ac1774ed 100644 --- a/kanidmd/lib/src/idm/server.rs +++ b/kanidmd/lib/src/idm/server.rs @@ -16,7 +16,6 @@ use kanidm_proto::v1::{ UnixGroupToken, UnixUserToken, UserAuthToken, }; use rand::prelude::*; -use time::OffsetDateTime; use tokio::sync::mpsc::{ 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::credupdatesession::CredentialUpdateSessionMutex; use crate::idm::delayed::{ - AuthSessionRecord, BackupCodeRemoval, DelayedAction, Oauth2ConsentGrant, PasswordUpgrade, - UnixPasswordUpgrade, WebauthnCounterIncrement, + AuthSessionRecord, BackupCodeRemoval, DelayedAction, Oauth2ConsentGrant, Oauth2SessionRecord, + PasswordUpgrade, UnixPasswordUpgrade, WebauthnCounterIncrement, }; #[cfg(test)] use crate::idm::event::PasswordChangeEvent; @@ -58,7 +57,7 @@ use crate::idm::AuthState; use crate::ldap::{LdapBoundToken, LdapSession}; use crate::prelude::*; 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>; type CredSoftLockMutex = Arc>; @@ -135,7 +134,7 @@ pub struct IdmServerProxyWriteTransaction<'a> { uat_jwt_signer: CowCellWriteTxn<'a, JwsSigner>, uat_jwt_validator: CowCellWriteTxn<'a, JwsValidator>, pub(crate) token_enc_key: CowCellWriteTxn<'a, Fernet>, - oauth2rs: Oauth2ResourceServersWriteTransaction<'a>, + pub(crate) oauth2rs: Oauth2ResourceServersWriteTransaction<'a>, } pub struct IdmServerDelayed { @@ -574,25 +573,54 @@ pub trait IdmServerTransaction<'a> { } } - fn check_account_uuid_valid( + fn check_oauth2_account_uuid_valid( &self, - uuid: &Uuid, + uuid: Uuid, + session_id: Uuid, + parent_session_id: Uuid, + iat: i64, ct: Duration, ) -> Result, OperationError> { - let entry = self.get_qs_txn().internal_search_uuid(uuid).map_err(|e| { - admin_error!(?e, "check_account_uuid_valid failed"); + let entry = self.get_qs_txn().internal_search_uuid(&uuid).map_err(|e| { + admin_error!(?e, "check_oauth2_account_uuid_valid failed"); e })?; - if Account::check_within_valid_time( + let within_valid_window = Account::check_within_valid_time( ct, entry.get_ava_single_datetime("account_valid_from").as_ref(), entry.get_ava_single_datetime("account_expire").as_ref(), - ) { - Account::try_from_entry_no_groups(entry.as_ref()).map(Some) - } else { - Ok(None) + ); + + if !within_valid_window { + 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 @@ -1528,7 +1556,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { ct: Duration, ) -> Result { 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( @@ -2103,13 +2131,9 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { pub(crate) fn process_authsessionrecord( &mut self, asr: &AuthSessionRecord, - ct: Duration, ) -> Result<(), OperationError> { // 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( asr.session_id, Session { @@ -2128,33 +2152,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { 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. - let modlist = ModifyList::new_list(mlist); + let modlist = ModifyList::new_append("user_auth_token_session", session); self.qs_write .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( &mut self, da: DelayedAction, - ct: Duration, + _ct: Duration, ) -> Result<(), OperationError> { match da { DelayedAction::PwUpgrade(pwu) => self.process_pwupgrade(&pwu), @@ -2200,7 +2233,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { DelayedAction::WebauthnCounterIncrement(wci) => self.process_webauthncounterinc(&wci), DelayedAction::BackupCodeRemoval(bcr) => self.process_backupcoderemoval(&bcr), 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_INC: &'static str = "ntaoentu nkrcgaeunhibwmwmqj;k wqjbkx "; const TEST_CURRENT_TIME: u64 = 6000; - const TEST_CURRENT_EXPIRE: u64 = TEST_CURRENT_TIME + AUTH_SESSION_TIMEOUT + 1; #[test] 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] fn test_idm_regenerate_radius_secret() { run_idm_test!( @@ -3728,7 +3731,8 @@ mod tests { idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed| { 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_b = Uuid::new_v4(); @@ -3747,7 +3751,7 @@ mod tests { target_uuid: UUID_ADMIN, session_id: session_a, 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_by: IdentityId::User(UUID_ADMIN), scope: AccessScope::IdentityOnly, @@ -3781,13 +3785,13 @@ mod tests { target_uuid: UUID_ADMIN, session_id: session_b, 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_by: IdentityId::User(UUID_ADMIN), scope: AccessScope::IdentityOnly, }); // 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); let idms_prox_read = task::block_on(idms.proxy_read()); @@ -3836,7 +3840,7 @@ mod tests { // Process the session info. let da = idms_delayed.try_recv().expect("invalid"); 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); let uat_unverified = diff --git a/kanidmd/lib/src/plugins/mod.rs b/kanidmd/lib/src/plugins/mod.rs index e52f92213..436da43a9 100644 --- a/kanidmd/lib/src/plugins/mod.rs +++ b/kanidmd/lib/src/plugins/mod.rs @@ -21,6 +21,7 @@ mod memberof; mod password_import; mod protected; mod refint; +mod session; mod spn; trait Plugin { @@ -165,6 +166,7 @@ impl Plugins { .and_then(|_| gidnumber::GidNumber::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(|_| session::SessionConsistency::pre_modify(qs, cand, me)) // attr unique should always be last .and_then(|_| attrunique::AttrUnique::pre_modify(qs, cand, me)) } diff --git a/kanidmd/lib/src/plugins/refint.rs b/kanidmd/lib/src/plugins/refint.rs index ae0f1595a..57ddd81e2 100644 --- a/kanidmd/lib/src/plugins/refint.rs +++ b/kanidmd/lib/src/plugins/refint.rs @@ -146,7 +146,7 @@ impl Plugin for ReferentialIntegrity { }) .map(|v| { v.to_ref_uuid() - .map(|uuid| PartialValue::new_uuid(*uuid)) + .map(|uuid| PartialValue::new_uuid(uuid)) .ok_or_else(|| { 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."); @@ -267,7 +267,11 @@ impl Plugin for ReferentialIntegrity { mod tests { use kanidm_proto::v1::PluginError; + use crate::event::CreateEvent; use crate::prelude::*; + use crate::value::{Oauth2Session, Session}; + use time::OffsetDateTime; + use uuid::uuid; // The create references a uuid that doesn't exist - reject #[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()); + } } diff --git a/kanidmd/lib/src/plugins/session.rs b/kanidmd/lib/src/plugins/session.rs new file mode 100644 index 000000000..ff297c92f --- /dev/null +++ b/kanidmd/lib/src/plugins/session.rs @@ -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>, + _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> = 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> = 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()); + } +} diff --git a/kanidmd/lib/src/schema.rs b/kanidmd/lib/src/schema.rs index 880593500..70ed7a30a 100644 --- a/kanidmd/lib/src/schema.rs +++ b/kanidmd/lib/src/schema.rs @@ -193,6 +193,7 @@ impl SchemaAttribute { SyntaxType::DeviceKey => matches!(v, PartialValue::DeviceKey(_)), // Allow refer types. SyntaxType::Session => matches!(v, PartialValue::Refer(_)), + SyntaxType::Oauth2Session => matches!(v, PartialValue::Refer(_)), // These are just insensitive string lookups on the hex-ified kid. SyntaxType::JwsKeyEs256 => matches!(v, PartialValue::Iutf8(_)), SyntaxType::JwsKeyRs256 => matches!(v, PartialValue::Iutf8(_)), @@ -239,6 +240,7 @@ impl SchemaAttribute { SyntaxType::Passkey => matches!(v, Value::Passkey(_, _, _)), SyntaxType::DeviceKey => matches!(v, Value::DeviceKey(_, _, _)), SyntaxType::Session => matches!(v, Value::Session(_, _)), + SyntaxType::Oauth2Session => matches!(v, Value::Oauth2Session(_, _)), SyntaxType::JwsKeyEs256 => matches!(v, Value::JwsKeyEs256(_)), 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. attributetypes.into_iter().for_each(|a| { // 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? // || a.syntax == SyntaxType::Session { diff --git a/kanidmd/lib/src/server.rs b/kanidmd/lib/src/server.rs index 68b22c9a9..d6efb95ab 100644 --- a/kanidmd/lib/src/server.rs +++ b/kanidmd/lib/src/server.rs @@ -90,6 +90,7 @@ pub struct QueryServerWriteTransaction<'a> { committed: bool, phase: CowCellWriteTxn<'a, ServerPhase>, d_info: CowCellWriteTxn<'a, DomainInfo>, + curtime: Duration, cid: Cid, be_txn: BackendWriteTransaction<'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::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::Oauth2Session => Err(OperationError::InvalidAttribute("Oauth2Session Values can not be supplied through modification".to_string())), } } None => { @@ -576,7 +578,10 @@ pub trait QueryServerTransaction<'a> { // ⚠️ Any types here need to also be added to update_attributes in // schema.rs for reference type / cache awareness during referential // 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. PartialValue::new_refer_s(value) .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 #[allow(clippy::expect_used)] let write_ticket = self @@ -1026,8 +1031,10 @@ impl QueryServer { let phase = self.phase.write(); #[allow(clippy::expect_used)] - let ts_max = be_txn.get_db_ts_max(ts).expect("Unable to get db_ts_max"); - let cid = Cid::new_lamport(self.s_uuid, d_info.d_uuid, ts, &ts_max); + let ts_max = be_txn + .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 { // I think this is *not* needed, because commit is mut self which should @@ -1039,6 +1046,7 @@ impl QueryServer { committed: false, phase, d_info, + curtime, cid, be_txn, schema: schema_write, @@ -1174,6 +1182,10 @@ impl QueryServer { } impl<'a> QueryServerWriteTransaction<'a> { + pub(crate) fn get_curtime(&self) -> Duration { + self.curtime + } + #[instrument(level = "debug", skip_all)] pub fn create(&mut self, ce: &CreateEvent) -> Result<(), OperationError> { // The create event is a raw, read only representation of the request @@ -2371,6 +2383,15 @@ impl<'a> QueryServerWriteTransaction<'a> { 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)] pub fn internal_modify( &mut self, @@ -2653,6 +2674,7 @@ impl<'a> QueryServerWriteTransaction<'a> { JSON_SCHEMA_ATTR_API_TOKEN_SESSION, JSON_SCHEMA_ATTR_OAUTH2_RS_SUP_SCOPE_MAP, JSON_SCHEMA_ATTR_USER_AUTH_TOKEN_SESSION, + JSON_SCHEMA_ATTR_OAUTH2_SESSION, JSON_SCHEMA_ATTR_NSUNIQUEID, JSON_SCHEMA_ATTR_OAUTH2_PREFER_SHORT_USERNAME, JSON_SCHEMA_ATTR_SYNC_TOKEN_SESSION, diff --git a/kanidmd/lib/src/value.rs b/kanidmd/lib/src/value.rs index 34101fb93..888399c5c 100644 --- a/kanidmd/lib/src/value.rs +++ b/kanidmd/lib/src/value.rs @@ -188,6 +188,7 @@ pub enum SyntaxType { Session = 25, JwsKeyEs256 = 26, JwsKeyRs256 = 27, + Oauth2Session = 28, } impl TryFrom<&str> for SyntaxType { @@ -225,6 +226,7 @@ impl TryFrom<&str> for SyntaxType { "SESSION" => Ok(SyntaxType::Session), "JWS_KEY_ES256" => Ok(SyntaxType::JwsKeyEs256), "JWS_KEY_RS256" => Ok(SyntaxType::JwsKeyRs256), + "OAUTH2SESSION" => Ok(SyntaxType::Oauth2Session), _ => Err(()), } } @@ -263,6 +265,7 @@ impl TryFrom for SyntaxType { 25 => Ok(SyntaxType::Session), 26 => Ok(SyntaxType::JwsKeyEs256), 27 => Ok(SyntaxType::JwsKeyRs256), + 28 => Ok(SyntaxType::Oauth2Session), _ => Err(()), } } @@ -299,6 +302,7 @@ impl fmt::Display for SyntaxType { SyntaxType::Session => "SESSION", SyntaxType::JwsKeyEs256 => "JWS_KEY_ES256", SyntaxType::JwsKeyRs256 => "JWS_KEY_RS256", + SyntaxType::Oauth2Session => "OAUTH2SESSION", }) } } @@ -750,6 +754,14 @@ pub struct Session { pub scope: AccessScope, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Oauth2Session { + pub parent: Uuid, + pub expiry: Option, + 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 /// 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. @@ -794,6 +806,7 @@ pub enum Value { TrustedDeviceEnrollment(Uuid), Session(Uuid, Session), + Oauth2Session(Uuid, Oauth2Session), JwsKeyEs256(JwsSigner), JwsKeyRs256(JwsSigner), @@ -1329,10 +1342,12 @@ impl Value { // We need a seperate to-ref_uuid to distinguish from normal uuids // in refint plugin. - pub fn to_ref_uuid(&self) -> Option<&Uuid> { + pub fn to_ref_uuid(&self) -> Option { match &self { - Value::Refer(u) => Some(u), - Value::OauthScopeMap(u, _) => Some(u), + Value::Refer(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, } } diff --git a/kanidmd/lib/src/valueset/mod.rs b/kanidmd/lib/src/valueset/mod.rs index 636107a17..d97f17b14 100644 --- a/kanidmd/lib/src/valueset/mod.rs +++ b/kanidmd/lib/src/valueset/mod.rs @@ -15,7 +15,7 @@ use crate::credential::Credential; use crate::prelude::*; use crate::repl::cid::Cid; use crate::schema::SchemaAttribute; -use crate::value::{Address, IntentTokenState, Session}; +use crate::value::{Address, IntentTokenState, Oauth2Session, Session}; mod address; mod binary; @@ -56,7 +56,7 @@ pub use self::nsuniqueid::ValueSetNsUniqueId; pub use self::oauth::{ValueSetOauthScope, ValueSetOauthScopeMap}; pub use self::restricted::ValueSetRestricted; pub use self::secret::ValueSetSecret; -pub use self::session::ValueSetSession; +pub use self::session::{ValueSetOauth2Session, ValueSetSession}; pub use self::spn::ValueSetSpn; pub use self::ssh::ValueSetSshKey; pub use self::syntax::ValueSetSyntax; @@ -471,6 +471,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { None } + fn as_oauth2session_map(&self) -> Option<&BTreeMap> { + debug_assert!(false); + None + } + fn to_jws_key_es256_single(&self) -> Option<&JwsSigner> { debug_assert!(false); None @@ -546,6 +551,7 @@ pub fn from_result_value_iter( | Value::DeviceKey(_, _, _) | Value::TrustedDeviceEnrollment(_) | Value::Session(_, _) + | Value::Oauth2Session(_, _) | Value::JwsKeyEs256(_) | Value::JwsKeyRs256(_) => { debug_assert!(false); @@ -601,6 +607,7 @@ pub fn from_value_iter(mut iter: impl Iterator) -> Result ValueSetJwsKeyEs256::new(k), Value::JwsKeyRs256(k) => ValueSetJwsKeyRs256::new(k), Value::Session(u, m) => ValueSetSession::new(u, m), + Value::Oauth2Session(u, m) => ValueSetOauth2Session::new(u, m), Value::PhoneNumber(_, _) | Value::TrustedDeviceEnrollment(_) => { debug_assert!(false); return Err(OperationError::InvalidValueState); @@ -644,6 +651,7 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result ValueSetPasskey::from_dbvs2(set), DbValueSetV2::DeviceKey(set) => ValueSetDeviceKey::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::JwsKeyRs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set), DbValueSetV2::PhoneNumber(_, _) | DbValueSetV2::TrustedDeviceEnrollment(_) => { diff --git a/kanidmd/lib/src/valueset/session.rs b/kanidmd/lib/src/valueset/session.rs index 5f99d4837..c36a1bb17 100644 --- a/kanidmd/lib/src/valueset/session.rs +++ b/kanidmd/lib/src/valueset/session.rs @@ -1,13 +1,15 @@ use std::collections::btree_map::Entry as BTreeEntry; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; 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::prelude::*; use crate::schema::SchemaAttribute; -use crate::value::Session; +use crate::value::{Oauth2Session, Session}; use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ValueSet}; #[derive(Debug, Clone)] @@ -250,3 +252,276 @@ impl ValueSetT for ValueSetSession { Some(Box::new(self.map.keys().copied())) } } + +// == oauth2 session == + +#[derive(Debug, Clone)] +pub struct ValueSetOauth2Session { + map: BTreeMap, + // 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, +} + +impl ValueSetOauth2Session { + pub fn new(u: Uuid, m: Oauth2Session) -> Box { + 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) -> Result { + 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> + }) + .transpose() + // Result, _> + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid expiry timestamp", + refer + ) + }) + // Option> + .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(iter: T) -> Option> + where + T: IntoIterator, + { + 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 { + 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 { + 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 + '_> { + 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 + '_> { + Box::new(self.map.keys().cloned().map(PartialValue::Refer)) + } + + fn to_value_iter(&self) -> Box + '_> { + 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> { + Some(&self.map) + } + + fn as_ref_uuid_iter(&self) -> Option + '_>> { + // 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())) + } +}