diff --git a/Cargo.lock b/Cargo.lock index aac75d72..d3b29a98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -360,9 +360,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "base64urlsafedata" @@ -534,9 +534,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.24" +version = "1.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" +checksum = "d0fc897dc1e865cc67c0e05a836d9d3f1df3cbe442aa4a9473b18e12624a4951" dependencies = [ "jobserver", "libc", @@ -609,9 +609,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.38" +version = "4.5.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" dependencies = [ "clap_builder", "clap_derive", @@ -619,9 +619,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.38" +version = "4.5.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" dependencies = [ "anstream", "anstyle", @@ -649,9 +649,9 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "color-eyre" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e1761c0e16f8883bbbb8ce5990867f4f06bf11a0253da6495a04ce4b6ef0ec" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" dependencies = [ "backtrace", "color-spantrace", @@ -664,9 +664,9 @@ dependencies = [ [[package]] name = "color-spantrace" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ddd8d5bfda1e11a501d0a7303f3bfed9aa632ebdb859be40d0fd70478ed70d5" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" dependencies = [ "once_cell", "owo-colors", @@ -1623,22 +1623,26 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.0", + "webpki-roots", ] [[package]] name = "hyper-util" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" +checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1837,6 +1841,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1952,9 +1966,9 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -2255,9 +2269,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.72" +version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ "bitflags", "cfg-if", @@ -2281,9 +2295,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.108" +version = "0.9.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" dependencies = [ "cc", "libc", @@ -2322,6 +2336,7 @@ dependencies = [ "tempfile", "thiserror 2.0.12", "tokio", + "tokio-util", "tower", "tower-http", "tracing", @@ -2431,9 +2446,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -2441,9 +2456,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", @@ -2951,14 +2966,13 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "futures-util", "http", "http-body", "http-body-util", @@ -2974,7 +2988,6 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", @@ -2983,13 +2996,13 @@ dependencies = [ "tokio", "tokio-rustls", "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.26.11", - "windows-registry", + "webpki-roots", ] [[package]] @@ -3206,15 +3219,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4177,16 +4181,18 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.4" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "async-compression", "bitflags", "bytes", "futures-core", + "futures-util", "http", "http-body", + "iri-string", "pin-project-lite", "tokio", "tokio-util", @@ -4684,15 +4690,6 @@ dependencies = [ "url", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.0", -] - [[package]] name = "webpki-roots" version = "1.0.0" @@ -4753,7 +4750,7 @@ dependencies = [ "windows-interface", "windows-link", "windows-result", - "windows-strings 0.4.2", + "windows-strings", ] [[package]] @@ -4784,17 +4781,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" -[[package]] -name = "windows-registry" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" -dependencies = [ - "windows-result", - "windows-strings 0.3.1", - "windows-targets 0.53.0", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -4804,15 +4790,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-strings" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -4873,29 +4850,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" -dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4908,12 +4869,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4926,12 +4881,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4944,24 +4893,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4974,12 +4911,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4992,12 +4923,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5010,12 +4935,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5028,12 +4947,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - [[package]] name = "winnow" version = "0.7.10" diff --git a/Cargo.toml b/Cargo.toml index 65f24a95..f5856469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ serde_bytes = { version = "0.11" } serde_json = { version = "1.0" } thiserror = { version = "2.0" } tokio = { version = "1.45", features = ["fs", "macros", "signal", "rt-multi-thread"] } +tokio-util = "0.7.15" tower = { version = "0.5" } tower-http = { version = "0.6", features = ["compression-full", "request-id", "sensitive-headers", "trace", "util"] } tracing = { version = "0.1" } diff --git a/migration/Cargo.lock b/migration/Cargo.lock index 6b9ab590..8c2ec43c 100644 --- a/migration/Cargo.lock +++ b/migration/Cargo.lock @@ -4554,12 +4554,14 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ "getrandom 0.3.1", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] diff --git a/migration/src/m20250414_000001_idp.rs b/migration/src/m20250414_000001_idp.rs index 69458181..08fca018 100644 --- a/migration/src/m20250414_000001_idp.rs +++ b/migration/src/m20250414_000001_idp.rs @@ -130,7 +130,7 @@ impl MigrationTrait for Migration { .col(string_len(FederatedAuthState::Nonce, 64)) .col(string_len(FederatedAuthState::RedirectUri, 256)) .col(string_len(FederatedAuthState::PkceVerifier, 64)) - .col(date_time(FederatedAuthState::StartedAt)) + .col(date_time(FederatedAuthState::ExpiresAt)) .col(json_null(FederatedAuthState::RequestedScope)) .foreign_key( ForeignKey::create() @@ -234,7 +234,7 @@ enum FederatedAuthState { Nonce, RedirectUri, PkceVerifier, - StartedAt, + ExpiresAt, RequestedScope, } diff --git a/src/api/error.rs b/src/api/error.rs index 656ead49..0adca732 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -23,8 +23,8 @@ use thiserror::Error; use tracing::error; use crate::api::v3::federation::error::OidcError; - use crate::assignment::error::AssignmentProviderError; +use crate::auth::AuthenticationError; use crate::catalog::error::CatalogProviderError; use crate::federation::error::FederationProviderError; use crate::identity::error::IdentityProviderError; @@ -43,6 +43,9 @@ pub enum KeystoneApiError { identifier: String, }, + #[error("Attempted to authenticate with an unsupported method.")] + AuthMethodNotSupported, + #[error("The request you have made requires authentication.")] Unauthorized, @@ -73,6 +76,12 @@ pub enum KeystoneApiError { source: AssignmentProviderError, }, + #[error(transparent)] + AuthenticationInfo { + //#[from] + source: crate::auth::AuthenticationError, + }, + #[error(transparent)] CatalogError { #[from] @@ -163,11 +172,37 @@ impl IntoResponse for KeystoneApiError { Json(json!({"error": {"code": StatusCode::UNAUTHORIZED.as_u16(), "message": self.to_string()}})), ).into_response() } - KeystoneApiError::InternalError(_) | KeystoneApiError::IdentityError { .. } | KeystoneApiError::ResourceError { .. } | KeystoneApiError::AssignmentError { .. } | KeystoneApiError::TokenError{..} | KeystoneApiError::Federation {..} | KeystoneApiError::Oidc{..} | KeystoneApiError::Other(..) => { + KeystoneApiError::AuthenticationInfo{ .. } => { + (StatusCode::UNAUTHORIZED, + Json(json!({"error": {"code": StatusCode::UNAUTHORIZED.as_u16(), "message": self.to_string()}})), + ).into_response() + } + KeystoneApiError::Forbidden => { + (StatusCode::FORBIDDEN, + Json(json!({"error": {"code": StatusCode::FORBIDDEN.as_u16(), "message": self.to_string()}})), + ).into_response() + } + KeystoneApiError::InternalError(_) | KeystoneApiError::IdentityError { .. } | KeystoneApiError::ResourceError { .. } | KeystoneApiError::AssignmentError { .. } | KeystoneApiError::TokenError{..} | KeystoneApiError::Federation{..} | KeystoneApiError::Other(..) => { (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": {"code": StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "message": self.to_string()}})), ).into_response() } + KeystoneApiError::Oidc{ source: ref err } => { + match err { + OidcError::OpenIdConnectReqwest{..} | OidcError::OpenIdConnectConfiguration{..} => { +( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": {"code": StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "message": self.to_string()}})), + ).into_response() + } + _ => { +( + StatusCode::BAD_REQUEST, + Json(json!({"error": {"code": StatusCode::BAD_REQUEST.as_u16(), "message": self.to_string()}})), + ).into_response() + } + } + } _ => { // KeystoneApiError::SubjectTokenMissing | KeystoneApiError::InvalidHeader | KeystoneApiError::InvalidToken | KeystoneApiError::Token{..} | KeystoneApiError::WebAuthN{..} | KeystoneApiError::Uuid {..} | KeystoneApiError::Serde {..} | KeystoneApiError::DomainIdOrName | KeystoneApiError::ProjectIdOrName | KeystoneApiError::ProjectDomain => (StatusCode::BAD_REQUEST, @@ -281,3 +316,14 @@ impl IntoResponse for WebauthnError { (StatusCode::INTERNAL_SERVER_ERROR, body).into_response() } } + +impl From for KeystoneApiError { + fn from(value: AuthenticationError) -> Self { + match value { + AuthenticationError::AuthenticatedInfoBuilder { source } => { + KeystoneApiError::InternalError(source.to_string()) + } + other => KeystoneApiError::AuthenticationInfo { source: other }, + } + } +} diff --git a/src/api/types.rs b/src/api/types.rs index 670ce12c..f0c236bb 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -165,13 +165,14 @@ impl From)>> for Catalog { /// order to uniquely identify the project by name. A domain scope may be specified by either the /// domain’s ID or name with equivalent results. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] pub enum Scope { /// Project scope - #[serde(rename = "project")] Project(ProjectScope), /// Domain scope - #[serde(rename = "domain")] Domain(Domain), + /// System scope + System(System), } /// Project scope information @@ -208,6 +209,15 @@ pub struct Project { pub domain: Domain, } +/// System scope +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(into))] +pub struct System { + /// system scope + #[builder(default)] + pub all: Option, +} + impl From for Domain { fn from(value: resource_provider_types::Domain) -> Self { Self { diff --git a/src/api/v3/auth/token/common.rs b/src/api/v3/auth/token/common.rs index ed19a632..d40fd00d 100644 --- a/src/api/v3/auth/token/common.rs +++ b/src/api/v3/auth/token/common.rs @@ -17,7 +17,7 @@ use crate::api::error::{KeystoneApiError, TokenError}; use crate::api::types::ProjectBuilder; use crate::api::v3::auth::token::types::{Token, TokenBuilder, UserBuilder}; use crate::api::v3::role::types::Role; -use crate::identity::{IdentityApi, types::UserResponse}; +use crate::identity::IdentityApi; use crate::keystone::ServiceState; use crate::resource::{ ResourceApi, @@ -26,19 +26,33 @@ use crate::resource::{ use crate::token::Token as ProviderToken; impl Token { - // TODO: Join both methods - pub async fn from_user_auth( + pub async fn from_provider_token( state: &ServiceState, token: &ProviderToken, - user: &UserResponse, - project: Option<&Project>, - domain: Option<&Domain>, ) -> Result { + println!("Token is {:?}", token); let mut response = TokenBuilder::default(); + let mut project: Option = token.project().cloned(); + let mut domain: Option = token.domain().cloned(); response.audit_ids(token.audit_ids().clone()); response.methods(token.methods().clone()); response.expires_at(*token.expires_at()); + let user = if let Some(user) = token.user() { + user + } else { + &state + .provider + .get_identity_provider() + .get_user(&state.db, token.user_id()) + .await + .map_err(KeystoneApiError::identity)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "user".into(), + identifier: token.user_id().clone(), + })? + }; + let user_domain = common::get_domain(state, Some(&user.domain_id), None::<&str>).await?; let mut user_response: UserBuilder = UserBuilder::default(); @@ -48,137 +62,116 @@ impl Token { user_response.domain(user_domain.clone()); response.user(user_response.build().map_err(TokenError::from)?); - match token { - ProviderToken::Unscoped(_token) => { - // Nothing to do - } - ProviderToken::DomainScope(_token) => { - response.domain(domain.ok_or(KeystoneApiError::InternalError( - "domain scope information missing".to_string(), - ))?); - } - ProviderToken::ProjectScope(token) => { - let project = project.ok_or(KeystoneApiError::InternalError( - "project scope information missing".to_string(), - ))?; - - let mut project_response = ProjectBuilder::default(); - project_response.id(project.id.clone()); - project_response.name(project.name.clone()); - if project.domain_id == user.domain_id { - project_response.domain(user_domain.clone().into()); - } else { - let project_domain = - common::get_domain(state, Some(&project.domain_id), None::<&str>).await?; - project_response.domain(project_domain.clone().into()); - } - response.project(project_response.build().map_err(TokenError::from)?); - - response.roles( - token - .roles - .clone() - .into_iter() - .map(Into::into) - .collect::>(), - ); - } - ProviderToken::ApplicationCredential(_token) => { - todo!(); - } - _ => { - todo!(); - } + if let Some(roles) = token.roles() { + response.roles( + roles + .clone() + .into_iter() + .map(Into::into) + .collect::>(), + ); } - Ok(response.build().map_err(TokenError::from)?) - } - - pub async fn from_provider_token( - state: &ServiceState, - token: &ProviderToken, - ) -> Result { - let mut response = TokenBuilder::default(); - response.audit_ids(token.audit_ids().clone()); - response.methods(token.methods().clone()); - response.expires_at(*token.expires_at()); - - let user = state - .provider - .get_identity_provider() - .get_user(&state.db, token.user_id()) - .await - .map_err(KeystoneApiError::identity)? - .ok_or_else(|| KeystoneApiError::NotFound { - resource: "user".into(), - identifier: token.user_id().clone(), - })?; - - let user_domain = common::get_domain(state, Some(&user.domain_id), None::<&str>).await?; - - let mut user_response: UserBuilder = UserBuilder::default(); - user_response.id(user.id.clone()); - user_response.name(user.name); - user_response.password_expires_at(user.password_expires_at); - user_response.domain(user_domain.clone()); - response.user(user_response.build().map_err(TokenError::from)?); match token { - ProviderToken::Unscoped(_token) => { - // Nothing to do - } + ProviderToken::Unscoped(_token) => {} ProviderToken::DomainScope(token) => { - if token.domain_id == user.domain_id { - response.domain(user_domain.clone()); - } else { - let domain = - common::get_domain(state, Some(&token.domain_id), None::<&str>).await?; - response.domain(domain.clone()); + if domain.is_none() { + domain = Some( + common::get_domain(state, Some(&token.domain_id), None::<&str>).await?, + ); } } ProviderToken::ProjectScope(token) => { - let project = state - .provider - .get_resource_provider() - .get_project(&state.db, &token.project_id) - .await - .map_err(KeystoneApiError::resource)? - .ok_or_else(|| KeystoneApiError::NotFound { - resource: "project".into(), - identifier: token.project_id.clone(), - })?; - - let mut project_response = ProjectBuilder::default(); - project_response.id(project.id.clone()); - project_response.name(project.name.clone()); - if project.domain_id == user.domain_id { - project_response.domain(user_domain.clone().into()); - } else { - let project_domain = - common::get_domain(state, Some(&project.domain_id), None::<&str>).await?; - project_response.domain(project_domain.clone().into()); + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); + } + } + ProviderToken::ApplicationCredential(token) => { + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); } - response.project(project_response.build().map_err(TokenError::from)?); - - response.roles( - token - .roles - .clone() - .into_iter() - .map(Into::into) - .collect::>(), - ); } - ProviderToken::ApplicationCredential(_token) => { - todo!(); + ProviderToken::FederationUnscoped(_token) => {} + ProviderToken::FederationDomainScope(token) => { + if domain.is_none() { + domain = Some( + common::get_domain(state, Some(&token.domain_id), None::<&str>).await?, + ); + } } - _ => { - todo!(); + ProviderToken::FederationProjectScope(token) => { + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); + } } } + + if let Some(domain) = domain { + response.domain(domain.clone()); + } + if let Some(project) = project { + response.project( + get_project_info_builder(state, &project, &user_domain) + .await? + .build() + .map_err(TokenError::from)?, + ); + } Ok(response.build().map_err(TokenError::from)?) } } +async fn get_project_info_builder( + state: &ServiceState, + project: &Project, + user_domain: &Domain, +) -> Result { + let mut project_response = ProjectBuilder::default(); + project_response.id(project.id.clone()); + project_response.name(project.name.clone()); + if project.domain_id == user_domain.id { + project_response.domain(user_domain.clone().into()); + } else { + let project_domain = + common::get_domain(state, Some(&project.domain_id), None::<&str>).await?; + project_response.domain(project_domain.clone().into()); + } + Ok(project_response) +} + #[cfg(test)] mod tests { use sea_orm::DatabaseConnection; @@ -375,22 +368,18 @@ mod tests { ) .unwrap(), ); - - let api_token = Token::from_provider_token( - &state, - &ProviderToken::ProjectScope(ProjectScopePayload { - user_id: "bar".into(), - project_id: "project_id".into(), - roles: vec![ProviderRole { - id: "rid".into(), - name: "role_name".into(), - ..Default::default() - }], + let token = ProviderToken::ProjectScope(ProjectScopePayload { + user_id: "bar".into(), + project_id: "project_id".into(), + roles: Some(vec![ProviderRole { + id: "rid".into(), + name: "role_name".into(), ..Default::default() - }), - ) - .await - .unwrap(); + }]), + ..Default::default() + }); + + let api_token = Token::from_provider_token(&state, &token).await.unwrap(); assert_eq!("bar", api_token.user.id); assert_eq!(Some("user_domain_id"), api_token.user.domain.id.as_deref()); diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index fc9a088f..3a993676 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -19,10 +19,7 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use base64::{Engine as _, engine::general_purpose::URL_SAFE}; -use tracing::debug; use utoipa_axum::{router::OpenApiRouter, routes}; -use uuid::Uuid; use crate::api::types::Scope; use crate::api::{ @@ -31,11 +28,10 @@ use crate::api::{ common::{find_project_from_scope, get_domain}, error::KeystoneApiError, }; +use crate::auth::{AuthenticatedInfo, AuthzInfo}; use crate::catalog::CatalogApi; use crate::identity::IdentityApi; -use crate::identity::types::UserResponse; use crate::keystone::ServiceState; -use crate::resource::types::{Domain, Project}; use crate::token::TokenApi; use types::{ AuthRequest, CreateTokenParameters, Token as ApiResponseToken, TokenResponse, @@ -49,6 +45,74 @@ pub(super) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new().routes(routes!(show, post)) } +/// Authenticate the user ignoring any scope information. It is important not to expose any +/// hints that user, project, domain, etc might exist before we have authenticated them by +/// taking different amount of time in case of certain validations. +async fn authenticate_request( + state: &ServiceState, + req: &AuthRequest, +) -> Result { + let mut authenticated_info: Option = None; + for method in req.auth.identity.methods.iter() { + if method == "password" { + if let Some(password_auth) = &req.auth.identity.password { + let req = password_auth.user.clone().try_into()?; + authenticated_info = Some( + state + .provider + .get_identity_provider() + .authenticate_by_password(&state.db, &state.provider, req) + .await?, + ); + } + } else if method == "token" { + if let Some(token) = &req.auth.identity.token { + authenticated_info = Some( + state + .provider + .get_token_provider() + .authenticate_by_token(&token.id, Some(false), None) + .await?, + ); + } + } + } + authenticated_info + .ok_or(KeystoneApiError::Unauthorized) + .and_then(|authn| { + authn.validate()?; + Ok(authn) + }) +} + +async fn get_authz_info( + state: &ServiceState, + req: &AuthRequest, +) -> Result { + let authz_info = match &req.auth.scope { + Some(Scope::Project(scope)) => { + if let Some(project) = find_project_from_scope(state, scope).await? { + AuthzInfo::Project(project) + } else { + return Err(KeystoneApiError::Unauthorized); + } + } + Some(Scope::Domain(scope)) => { + if let Ok(domain) = get_domain(state, scope.id.as_ref(), scope.name.as_ref()).await { + AuthzInfo::Domain(domain) + } else { + return Err(KeystoneApiError::Unauthorized); + } + } + Some(Scope::System(_scope)) => { + todo!() + } + None => AuthzInfo::Unscoped, + }; + authz_info.validate()?; + Ok(authz_info) +} + /// Authenticate user issuing a new token #[utoipa::path( post, @@ -66,127 +130,43 @@ async fn post( State(state): State, Json(req): Json, ) -> Result { - let mut methods: Vec = Vec::new(); - let mut user: Option = None; - let mut project: Option = None; - let mut domain: Option = None; - debug!("Scope is {:?}", req.auth.scope); - - match req.auth.scope { - Some(Scope::Project(scope)) => { - project = find_project_from_scope(&state, &scope).await?; - if !project.as_ref().is_some_and(|target| target.enabled) { - return Err(KeystoneApiError::Unauthorized); - } - } - Some(Scope::Domain(scope)) => { - domain = Some(get_domain(&state, scope.id.as_ref(), scope.name.as_ref()).await?); - if !domain.as_ref().is_some_and(|target| target.enabled) { - return Err(KeystoneApiError::Unauthorized); - } - } - None => {} - } + let authed_info = authenticate_request(&state, &req).await?; + let authz_info = get_authz_info(&state, &req).await?; - for method in req.auth.identity.methods.iter() { - if method == "password" { - if let Some(password_auth) = &req.auth.identity.password { - let req = password_auth.user.clone().try_into()?; - user = Some( - state - .provider - .get_identity_provider() - .authenticate_by_password(&state.db, &state.provider, req) - .await?, - ); - methods.push(method.clone()); - } - } else if method == "token" { - if let Some(token) = &req.auth.identity.token { - let current_token = state - .provider - .get_token_provider() - .validate_token(&token.id, Some(false), None) - .await - .map_err(|_| KeystoneApiError::NotFound { - resource: "token".into(), - identifier: String::new(), - })?; - user = state - .provider - .get_identity_provider() - .get_user(&state.db, current_token.user_id()) - .await - .map_err(|_| KeystoneApiError::NotFound { - resource: "user".into(), - identifier: current_token.user_id().clone(), - })?; - } - } - } + println!("Authz info is {:?}", authz_info); - if let Some(authed_user) = &user { - let mut token = state.provider.get_token_provider().issue_token( - authed_user.id.clone(), - methods, - Vec::::from([URL_SAFE - .encode(Uuid::new_v4().as_bytes()) - .trim_end_matches('=') - .to_string()]), - project.as_ref(), - domain.as_ref(), - )?; - - state - .provider - .get_token_provider() - .populate_role_assignments(&mut token, &state.db, &state.provider) - .await - .map_err(|_| KeystoneApiError::Forbidden)?; + let mut token = state + .provider + .get_token_provider() + .issue_token(authed_info, authz_info)?; - state - .provider - .get_token_provider() - .expand_project_information(&mut token, &state.db, &state.provider) - .await?; + token = state + .provider + .get_token_provider() + .expand_token_information(&token, &state.db, &state.provider) + .await?; - state + let mut api_token = TokenResponse { + token: ApiResponseToken::from_provider_token(&state, &token).await?, + }; + if !query.nocatalog.is_some_and(|x| x) { + let catalog: Catalog = state .provider - .get_token_provider() - .expand_domain_information(&mut token, &state.db, &state.provider) - .await?; - - let mut api_token = TokenResponse { - token: ApiResponseToken::from_user_auth( - &state, - &token, - authed_user, - project.as_ref(), - domain.as_ref(), - ) - .await?, - }; - if !query.nocatalog.is_some_and(|x| x) { - let catalog: Catalog = state - .provider - .get_catalog_provider() - .get_catalog(&state.db, true) - .await? - .into(); - api_token.token.catalog = Some(catalog); - } - return Ok(( - StatusCode::OK, - [( - "X-Subject-Token", - state.provider.get_token_provider().encode_token(&token)?, - )], - Json(api_token), - ) - .into_response()); + .get_catalog_provider() + .get_catalog(&state.db, true) + .await? + .into(); + api_token.token.catalog = Some(catalog); } - - return Err(KeystoneApiError::Unauthorized); + return Ok(( + StatusCode::OK, + [( + "X-Subject-Token", + state.provider.get_token_provider().encode_token(&token)?, + )], + Json(api_token), + ) + .into_response()); } /// Validate token @@ -228,25 +208,24 @@ async fn show( identifier: String::new(), })?; - state + token = state .provider .get_token_provider() - .populate_role_assignments(&mut token, &state.db, &state.provider) - .await?; + .expand_token_information(&token, &state.db, &state.provider) + .await + .map_err(|_| KeystoneApiError::Forbidden)?; - state - .provider - .get_token_provider() - .expand_project_information(&mut token, &state.db, &state.provider) - .await?; + let mut response_token = ApiResponseToken::from_provider_token(&state, &token).await?; - state - .provider - .get_token_provider() - .expand_domain_information(&mut token, &state.db, &state.provider) - .await?; - - let response_token = ApiResponseToken::from_provider_token(&state, &token).await?; + if !query.nocatalog.is_some_and(|x| x) { + let catalog: Catalog = state + .provider + .get_catalog_provider() + .get_catalog(&state.db, true) + .await? + .into(); + response_token.catalog = Some(catalog); + } Ok(TokenResponse { token: response_token, @@ -265,13 +244,18 @@ mod tests { use std::sync::Arc; use tower::ServiceExt; // for `call`, `oneshot`, and `ready` use tower_http::trace::TraceLayer; + use tracing_test::traced_test; use super::openapi_router; - use crate::api::v3::auth::token::types::TokenResponse; + use crate::api::v3::auth::token::types::*; use crate::assignment::MockAssignmentProvider; + use crate::auth::AuthenticatedInfo; use crate::catalog::MockCatalogProvider; use crate::config::Config; - use crate::identity::{MockIdentityProvider, types::UserResponse}; + use crate::identity::{ + MockIdentityProvider, + types::{UserPasswordAuthRequest, UserResponse}, + }; use crate::keystone::Service; use crate::provider::Provider; use crate::resource::{ @@ -279,7 +263,148 @@ mod tests { types::{Domain, Project}, }; use crate::tests::api::get_mocked_state_unauthed; - use crate::token::*; + use crate::token::{ + MockTokenProvider, ProjectScopePayload, Token as ProviderToken, TokenProviderError, + UnscopedPayload, + }; + + use super::*; + + #[tokio::test] + async fn test_authenticate_request_password() { + let config = Config::default(); + let auth_info = AuthenticatedInfo::builder() + .user_id("uid") + .user(UserResponse { + id: "uid".to_string(), + domain_id: "udid".into(), + ..Default::default() + }) + .build() + .unwrap(); + let auth_clone = auth_info.clone(); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_authenticate_by_password() + .withf(|_, _, req: &UserPasswordAuthRequest| { + req.id == Some("uid".to_string()) + && req.password == "pwd" + && req.name == Some("uname".to_string()) + }) + .returning(move |_, _, _| Ok(auth_clone.clone())); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .identity(identity_mock) + .build() + .unwrap(); + + let state = + Arc::new(Service::new(config, DatabaseConnection::Disconnected, provider).unwrap()); + + assert_eq!( + auth_info, + authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["password".to_string()], + password: Some(PasswordAuth { + user: UserPassword { + id: Some("uid".to_string()), + password: "pwd".to_string(), + name: Some("uname".to_string()), + ..Default::default() + }, + }), + token: None, + }, + scope: None, + }, + } + ) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn test_authenticate_request_token() { + let config = Config::default(); + + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_authenticate_by_token() + .withf( + |id: &'_ str, allow_expired: &Option, window: &Option| { + id == "fake_token" && *allow_expired == Some(false) && window.is_none() + }, + ) + .returning(|_, _, _| Ok(AuthenticatedInfo::builder().user_id("uid").build().unwrap())); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .token(token_mock) + .build() + .unwrap(); + + let state = + Arc::new(Service::new(config, DatabaseConnection::Disconnected, provider).unwrap()); + + assert_eq!( + AuthenticatedInfo::builder().user_id("uid").build().unwrap(), + authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["token".to_string()], + password: None, + token: Some(TokenAuth { + id: "fake_token".to_string() + }), + }, + scope: None, + }, + } + ) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn test_authenticate_request_unsupported() { + let config = Config::default(); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .build() + .unwrap(); + + let state = + Arc::new(Service::new(config, DatabaseConnection::Disconnected, provider).unwrap()); + + let rsp = authenticate_request( + &state, + &AuthRequest { + auth: AuthRequestInner { + identity: Identity { + methods: vec!["fake".to_string()], + password: None, + token: None, + }, + scope: None, + }, + }, + ) + .await; + if let KeystoneApiError::Unauthorized = rsp.unwrap_err() { + } else { + panic!("Should receive Unauthorized"); + } + } #[tokio::test] async fn test_get() { @@ -304,7 +429,7 @@ mod tests { }); let mut token_mock = MockTokenProvider::default(); token_mock.expect_validate_token().returning(|_, _, _| { - Ok(Token::Unscoped(UnscopedPayload { + Ok(ProviderToken::Unscoped(UnscopedPayload { user_id: "bar".into(), ..Default::default() })) @@ -313,16 +438,23 @@ mod tests { .expect_populate_role_assignments() .returning(|_, _, _| Ok(())); token_mock - .expect_expand_project_information() - .returning(|_, _, _| Ok(())); - token_mock - .expect_expand_domain_information() - .returning(|_, _, _| Ok(())); + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + let mut catalog_mock = MockCatalogProvider::default(); + catalog_mock + .expect_get_catalog() + .returning(|_, _| Ok(Vec::new())); let provider = Provider::mocked_builder() .identity(identity_mock) .resource(resource_mock) .token(token_mock) + .catalog(catalog_mock) .build() .unwrap(); @@ -398,7 +530,7 @@ mod tests { .expect_validate_token() .withf(|token: &'_ str, _, _| token == "foo") .returning(|_, _, _| { - Ok(Token::Unscoped(UnscopedPayload { + Ok(ProviderToken::Unscoped(UnscopedPayload { user_id: "bar".into(), ..Default::default() })) @@ -409,7 +541,7 @@ mod tests { token == "bar" && *allow_expired == Some(true) }) .returning(|_, _, _| { - Ok(Token::Unscoped(UnscopedPayload { + Ok(ProviderToken::Unscoped(UnscopedPayload { user_id: "bar".into(), ..Default::default() })) @@ -418,16 +550,23 @@ mod tests { .expect_populate_role_assignments() .returning(|_, _, _| Ok(())); token_mock - .expect_expand_project_information() - .returning(|_, _, _| Ok(())); - token_mock - .expect_expand_domain_information() - .returning(|_, _, _| Ok(())); + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + let mut catalog_mock = MockCatalogProvider::default(); + catalog_mock + .expect_get_catalog() + .returning(|_, _| Ok(Vec::new())); let provider = Provider::mocked_builder() .identity(identity_mock) .resource(resource_mock) .token(token_mock) + .catalog(catalog_mock) .build() .unwrap(); @@ -467,7 +606,7 @@ mod tests { .expect_validate_token() .withf(|token: &'_ str, _, _| token == "foo") .returning(|_, _, _| { - Ok(Token::Unscoped(UnscopedPayload { + Ok(ProviderToken::Unscoped(UnscopedPayload { user_id: "bar".into(), ..Default::default() })) @@ -529,8 +668,25 @@ mod tests { } #[tokio::test] + #[traced_test] async fn test_post() { let config = Config::default(); + let project = Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: true, + ..Default::default() + }; + let user_domain = Domain { + id: "user_domain_id".into(), + enabled: true, + ..Default::default() + }; + let project_domain = Domain { + id: "pdid".into(), + enabled: true, + ..Default::default() + }; let mut assignment_mock = MockAssignmentProvider::default(); let mut catalog_mock = MockCatalogProvider::default(); assignment_mock @@ -540,51 +696,47 @@ mod tests { let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_authenticate_by_password() + .withf(|_, _, req: &UserPasswordAuthRequest| { + req.id == Some("uid".to_string()) + && req.password == "pass" + && req.name == Some("uname".to_string()) + }) .returning(|_, _, _| { - Ok(UserResponse { - id: "uid".to_string(), - domain_id: "user_domain_id".into(), - ..Default::default() - }) + Ok(AuthenticatedInfo::builder() + .user_id("uid") + .user(UserResponse { + id: "uid".to_string(), + domain_id: "udid".into(), + ..Default::default() + }) + .build() + .unwrap()) }); let mut resource_mock = MockResourceProvider::default(); resource_mock .expect_get_project() .withf(|_: &DatabaseConnection, id: &'_ str| id == "pid") - .returning(|_, _| { - Ok(Some(Project { - id: "pid".into(), - domain_id: "pdid".into(), - enabled: true, - ..Default::default() - })) - }); + .returning(move |_, _| Ok(Some(project.clone()))); resource_mock .expect_get_domain() .withf(|_: &DatabaseConnection, id: &'_ str| id == "user_domain_id") - .returning(|_, _| { - Ok(Some(Domain { - id: "user_domain_id".into(), - enabled: true, - ..Default::default() - })) - }); + .returning(move |_, _| Ok(Some(user_domain.clone()))); resource_mock .expect_get_domain() .withf(|_: &DatabaseConnection, id: &'_ str| id == "pdid") - .returning(|_, _| { - Ok(Some(Domain { - id: "pdid".into(), - enabled: true, - ..Default::default() - })) - }); + .returning(move |_, _| Ok(Some(project_domain.clone()))); let mut token_mock = MockTokenProvider::default(); - token_mock.expect_issue_token().returning(|_, _, _, _, _| { - Ok(Token::ProjectScope(ProjectScopePayload { + token_mock.expect_issue_token().returning(|_, _| { + Ok(ProviderToken::ProjectScope(ProjectScopePayload { user_id: "bar".into(), methods: Vec::from(["password".to_string()]), + user: Some(UserResponse { + id: "uid".to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + }), + project_id: "pid".into(), ..Default::default() })) }); @@ -592,11 +744,26 @@ mod tests { .expect_populate_role_assignments() .returning(|_, _, _| Ok(())); token_mock - .expect_expand_project_information() - .returning(|_, _, _| Ok(())); - token_mock - .expect_expand_domain_information() - .returning(|_, _, _| Ok(())); + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(ProviderToken::ProjectScope(ProjectScopePayload { + user_id: "bar".into(), + methods: Vec::from(["password".to_string()]), + user: Some(UserResponse { + id: "uid".to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + }), + project_id: "pid".into(), + project: Some(Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: true, + ..Default::default() + }), + ..Default::default() + })) + }); token_mock .expect_encode_token() .returning(|_| Ok("token".to_string())); @@ -669,4 +836,95 @@ mod tests { let res: TokenResponse = serde_json::from_slice(&body).unwrap(); assert_eq!(vec!["password"], res.token.methods); } + + #[tokio::test] + async fn test_post_project_disabled() { + let config = Config::default(); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_authenticate_by_password() + .returning(|_, _, _| { + Ok(AuthenticatedInfo::builder() + .user_id("uid") + .user(UserResponse { + id: "uid".to_string(), + domain_id: "udid".into(), + ..Default::default() + }) + .build() + .unwrap()) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "pid") + .returning(move |_, _| { + Ok(Some(Project { + id: "pid".into(), + domain_id: "pdid".into(), + enabled: false, + ..Default::default() + })) + }); + + let provider = Provider::mocked_builder() + .config(config.clone()) + .identity(identity_mock) + .resource(resource_mock) + .build() + .unwrap(); + + let state = + Arc::new(Service::new(config, DatabaseConnection::Disconnected, provider).unwrap()); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "id": "uid", + "name": "uname", + "domain": { + "id": "udid", + "name": "udname" + }, + "password": "pass", + }, + }, + }, + "scope": { + "project": { + "id": "pid", + "name": "pname", + "domain": { + "id": "pdid", + "name": "pdname" + } + } + } + } + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } } diff --git a/src/api/v3/federation/auth.rs b/src/api/v3/federation/auth.rs index bd9df174..54d1aca8 100644 --- a/src/api/v3/federation/auth.rs +++ b/src/api/v3/federation/auth.rs @@ -15,10 +15,9 @@ use axum::{ Json, debug_handler, extract::{Path, State}, - http::{StatusCode, header::LOCATION}, response::IntoResponse, }; -use chrono::Local; +use chrono::{Local, TimeDelta}; use std::collections::HashSet; use tracing::debug; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -29,120 +28,121 @@ use openidconnect::{ ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, PkceCodeChallenge, RedirectUrl, Scope, }; -use crate::api::types::Scope as ApiScope; +use crate::api::error::KeystoneApiError; use crate::api::v3::federation::error::OidcError; use crate::api::v3::federation::types::*; -use crate::api::{ - common::{find_project_from_scope, get_domain}, - error::KeystoneApiError, -}; use crate::federation::FederationApi; -use crate::federation::types::{ - AuthState, MappingListParameters as ProviderMappingListParameters, Scope as ProviderScope, -}; +use crate::federation::types::{AuthState, MappingListParameters as ProviderMappingListParameters}; use crate::keystone::ServiceState; pub(super) fn openapi_router() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(post, get)) + OpenApiRouter::new().routes(routes!(post)) } -/// Authenticate using identity provider -#[utoipa::path( - get, - path = "/identity_providers/{idp_id}/auth", - description = "Authenticate using identity provider", - responses( - (status = CREATED, description = "identity provider object", body = IdentityProviderAuthResponse), - ), - tag="identity_providers" -)] -#[tracing::instrument(name = "api::identity_provider_auth", level = "debug", skip(state))] -#[debug_handler] -pub async fn get( - State(state): State, - Path(idp_id): Path, -) -> Result { - let idp = state - .provider - .get_federation_provider() - .get_identity_provider(&state.db, &idp_id) - .await - .map(|x| { - x.ok_or_else(|| KeystoneApiError::NotFound { - resource: "identity provider".into(), - identifier: idp_id, - }) - })??; - - if let Some(discovery_url) = &idp.oidc_discovery_url { - let http_client = reqwest::ClientBuilder::new() - // Following redirects opens the client up to SSRF vulnerabilities. - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("Client should build"); - - let provider_metadata = CoreProviderMetadata::discover_async( - IssuerUrl::new(discovery_url.to_string()).map_err(OidcError::from)?, - &http_client, - ) - .await - .map_err(|err| OidcError::discovery(&err))?; - let client = CoreClient::from_provider_metadata( - provider_metadata, - ClientId::new(idp.oidc_client_id.expect("client_id is mandatory")), - idp.oidc_client_secret.map(ClientSecret::new), - ) - // Set the URL the user will be redirected to after the authorization process. - .set_redirect_uri( - RedirectUrl::new("http://localhost:8080/v3/federation/auth/callback".to_string()) - .map_err(OidcError::from)?, - ); - - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - - // Generate the full authorization URL. - let (auth_url, csrf_token, nonce) = client - .authorize_url( - CoreAuthenticationFlow::AuthorizationCode, - CsrfToken::new_random, - Nonce::new_random, - ) - // Set the desired scopes. - .add_scope(Scope::new("openid".to_string())) - // Set the PKCE code challenge. - .set_pkce_challenge(pkce_challenge) - .url(); - - state - .provider - .get_federation_provider() - .create_auth_state( - &state.db, - AuthState { - state: csrf_token.secret().clone(), - nonce: nonce.secret().clone(), - idp_id: idp.id.clone(), - mapping_id: String::from("kc"), - redirect_uri: String::new(), - pkce_verifier: pkce_verifier.into_secret(), - started_at: Local::now().into(), - scope: None, - }, - ) - .await?; - - debug!( - "url: {:?}, csrf: {:?}, nonce: {:?}", - auth_url, - csrf_token.secret(), - nonce.secret() - ); - return Ok((StatusCode::FOUND, [(LOCATION, &auth_url.to_string())]).into_response()); - //return Ok(Redirect::with_status_code(StatusCode::FOUND, &auth_url.to_string()).into_response()); - } - - Ok((StatusCode::CREATED).into_response()) -} +// /// Authenticate using identity provider +// #[utoipa::path( +// get, +// path = "/identity_providers/{idp_id}/auth", +// description = "Authenticate using identity provider", +// responses( +// (status = CREATED, description = "identity provider object", body = IdentityProviderAuthResponse), +// ), +// tag="identity_providers" +// )] +// #[tracing::instrument(name = "api::identity_provider_auth", level = "debug", skip(state))] +// #[debug_handler] +// pub async fn get( +// State(state): State, +// Path(idp_id): Path, +// ) -> Result { +// state +// .config +// .auth +// .methods +// .iter() +// .find(|m| *m == "openid") +// .ok_or(KeystoneApiError::AuthMethodNotSupported)?; +// +// let idp = state +// .provider +// .get_federation_provider() +// .get_identity_provider(&state.db, &idp_id) +// .await +// .map(|x| { +// x.ok_or_else(|| KeystoneApiError::NotFound { +// resource: "identity provider".into(), +// identifier: idp_id, +// }) +// })??; +// +// if let Some(discovery_url) = &idp.oidc_discovery_url { +// let http_client = reqwest::ClientBuilder::new() +// // Following redirects opens the client up to SSRF vulnerabilities. +// .redirect(reqwest::redirect::Policy::none()) +// .build() +// .map_err(OidcError::from)?; +// +// let provider_metadata = CoreProviderMetadata::discover_async( +// IssuerUrl::new(discovery_url.to_string()).map_err(OidcError::from)?, +// &http_client, +// ) +// .await +// .map_err(|err| OidcError::discovery(&err))?; +// let client = CoreClient::from_provider_metadata( +// provider_metadata, +// ClientId::new(idp.oidc_client_id.ok_or(OidcError::ClientIdRequired)?), +// idp.oidc_client_secret.map(ClientSecret::new), +// ) +// // Set the URL the user will be redirected to after the authorization process. +// .set_redirect_uri( +// RedirectUrl::new("http://localhost:8080/v3/federation/auth/callback".to_string()) +// .map_err(OidcError::from)?, +// ); +// +// let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); +// +// // Generate the full authorization URL. +// let (auth_url, csrf_token, nonce) = client +// .authorize_url( +// CoreAuthenticationFlow::AuthorizationCode, +// CsrfToken::new_random, +// Nonce::new_random, +// ) +// // Set the desired scopes. +// .add_scope(Scope::new("openid".to_string())) +// // Set the PKCE code challenge. +// .set_pkce_challenge(pkce_challenge) +// .url(); +// +// state +// .provider +// .get_federation_provider() +// .create_auth_state( +// &state.db, +// AuthState { +// state: csrf_token.secret().clone(), +// nonce: nonce.secret().clone(), +// idp_id: idp.id.clone(), +// mapping_id: String::from("kc"), +// redirect_uri: String::new(), +// pkce_verifier: pkce_verifier.into_secret(), +// expires_at: Local::now().into(), +// scope: None, +// }, +// ) +// .await?; +// +// debug!( +// "url: {:?}, csrf: {:?}, nonce: {:?}", +// auth_url, +// csrf_token.secret(), +// nonce.secret() +// ); +// return Ok((StatusCode::FOUND, [(LOCATION, &auth_url.to_string())]).into_response()); +// } +// +// Ok((StatusCode::CREATED).into_response()) +// } /// Authenticate using identity provider #[utoipa::path( @@ -161,6 +161,14 @@ pub async fn post( Path(idp_id): Path, Json(req): Json, ) -> Result { + state + .config + .auth + .methods + .iter() + .find(|m| *m == "openid") + .ok_or(KeystoneApiError::AuthMethodNotSupported)?; + let idp = state .provider .get_federation_provider() @@ -213,7 +221,7 @@ pub async fn post( // Following redirects opens the client up to SSRF vulnerabilities. .redirect(reqwest::redirect::Policy::none()) .build() - .expect("Client should build"); + .map_err(OidcError::from)?; let provider_metadata = CoreProviderMetadata::discover_async( IssuerUrl::new(discovery_url.to_string()).map_err(OidcError::from)?, @@ -223,7 +231,7 @@ pub async fn post( .map_err(|err| OidcError::discovery(&err))?; CoreClient::from_provider_metadata( provider_metadata, - ClientId::new(idp.oidc_client_id.expect("client_id is mandatory")), + ClientId::new(idp.oidc_client_id.ok_or(OidcError::ClientIdRequired)?), idp.oidc_client_secret.map(ClientSecret::new), ) // Set the URL the user will be redirected to after the authorization process. @@ -254,17 +262,6 @@ pub async fn post( .set_pkce_challenge(pkce_challenge) .url(); - let scope: Option = match req.scope { - Some(ApiScope::Project(scope)) => find_project_from_scope(&state, &scope) - .await? - .map(|x| ProviderScope::Project(x.id.clone())), - Some(ApiScope::Domain(scope)) => get_domain(&state, scope.id.as_ref(), scope.name.as_ref()) - .await - .map(|x| ProviderScope::Domain(x.id.clone())) - .ok(), - _ => None, - }; - state .provider .get_federation_provider() @@ -277,8 +274,9 @@ pub async fn post( mapping_id: mapping.id.clone(), redirect_uri: req.redirect_uri.clone(), pkce_verifier: pkce_verifier.into_secret(), - started_at: Local::now().into(), - scope, + expires_at: (Local::now() + TimeDelta::seconds(180)).into(), + // TODO: Make this configurable + scope: req.scope.map(Into::into), }, ) .await?; diff --git a/src/api/v3/federation/error.rs b/src/api/v3/federation/error.rs index 80f95a76..be0d17ee 100644 --- a/src/api/v3/federation/error.rs +++ b/src/api/v3/federation/error.rs @@ -39,6 +39,18 @@ pub enum OidcError { source: openidconnect::ClaimsVerificationError, }, + #[error(transparent)] + OpenIdConnectReqwest { + #[from] + source: openidconnect::reqwest::Error, + }, + + #[error(transparent)] + OpenIdConnectConfiguration { + #[from] + source: openidconnect::ConfigurationError, + }, + #[error("error parsing the url")] UrlParse { #[from] @@ -48,10 +60,13 @@ pub enum OidcError { #[error("server did not returned an ID token")] NoToken, + #[error("Identity Provider client_id is missing")] + ClientIdRequired, + #[error("ID token does not contain user id claim {0}")] - UserIdClaimMissing(String), + UserIdClaimRequired(String), #[error("ID token does not contain user id claim {0}")] - UserNameClaimMissing(String), + UserNameClaimRequired(String), #[error("can not identify resulting domain_id for the user")] UserDomainUnbound, @@ -72,6 +87,14 @@ pub enum OidcError { #[allow(private_interfaces)] source: MappedUserDataBuilderError, }, + + #[error(transparent)] + AuthenticationInfo { + #[from] + source: crate::auth::AuthenticationError, + }, + #[error("Authentication expired")] + AuthStateExpired, } impl OidcError { diff --git a/src/api/v3/federation/oidc.rs b/src/api/v3/federation/oidc.rs index 2cb9bd54..64452f16 100644 --- a/src/api/v3/federation/oidc.rs +++ b/src/api/v3/federation/oidc.rs @@ -13,13 +13,12 @@ // SPDX-License-Identifier: Apache-2.0 use axum::{Json, debug_handler, extract::State, http::StatusCode, response::IntoResponse}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +use chrono::Utc; use eyre::WrapErr; use serde_json::Value; use tracing::debug; use url::Url; use utoipa_axum::{router::OpenApiRouter, routes}; -use uuid::Uuid; use openidconnect::core::{CoreGenderClaim, CoreProviderMetadata}; use openidconnect::reqwest; @@ -28,30 +27,56 @@ use openidconnect::{ RedirectUrl, TokenResponse, }; +use crate::api::common::{find_project_from_scope, get_domain}; use crate::api::v3::auth::token::types::{ Token as ApiResponseToken, TokenResponse as KeystoneTokenResponse, }; use crate::api::v3::federation::error::OidcError; use crate::api::v3::federation::types::*; use crate::api::{Catalog, error::KeystoneApiError}; +use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo}; use crate::catalog::CatalogApi; use crate::federation::FederationApi; -use crate::federation::types::Scope as ProviderScope; use crate::federation::types::{ - identity_provider::IdentityProvider as ProviderIdentityProvider, + Scope as ProviderScope, identity_provider::IdentityProvider as ProviderIdentityProvider, mapping::Mapping as ProviderMapping, }; use crate::identity::IdentityApi; use crate::identity::error::IdentityProviderError; use crate::identity::types::{FederationBuilder, FederationProtocol, UserCreateBuilder}; use crate::keystone::ServiceState; -use crate::resource::ResourceApi; use crate::token::TokenApi; pub(super) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new().routes(routes!(callback)) } +async fn get_authz_info( + state: &ServiceState, + scope: Option<&ProviderScope>, +) -> Result { + let authz_info = match scope { + Some(ProviderScope::Project(scope)) => { + if let Some(project) = find_project_from_scope(state, &scope.into()).await? { + AuthzInfo::Project(project) + } else { + return Err(KeystoneApiError::Unauthorized); + } + } + Some(ProviderScope::Domain(scope)) => { + if let Ok(domain) = get_domain(state, scope.id.as_ref(), scope.name.as_ref()).await { + AuthzInfo::Domain(domain) + } else { + return Err(KeystoneApiError::Unauthorized); + } + } + Some(ProviderScope::System(_scope)) => todo!(), + None => AuthzInfo::Unscoped, + }; + authz_info.validate()?; + Ok(authz_info) +} + /// Authenticate callback #[utoipa::path( post, @@ -86,6 +111,10 @@ pub async fn callback( identifier: query.state.clone(), })?; + if auth_state.expires_at < Utc::now() { + return Err(OidcError::AuthStateExpired)?; + } + let idp = state .provider .get_federation_provider() @@ -110,12 +139,11 @@ pub async fn callback( }) })??; - debug!("Got code {:?}, state: {:?}", query.code, auth_state); let http_client = reqwest::ClientBuilder::new() // Following redirects opens the client up to SSRF vulnerabilities. .redirect(reqwest::redirect::Policy::none()) .build() - .expect("Client should build"); + .map_err(OidcError::from)?; let client = if let Some(discovery_url) = &idp.oidc_discovery_url { let provider_metadata = CoreProviderMetadata::discover_async( @@ -126,7 +154,11 @@ pub async fn callback( .map_err(|err| OidcError::discovery(&err))?; OidcClient::from_provider_metadata( provider_metadata, - ClientId::new(idp.oidc_client_id.clone().expect("client_id is mandatory")), + ClientId::new( + idp.oidc_client_id + .clone() + .ok_or(OidcError::ClientIdRequired)?, + ), idp.oidc_client_secret.clone().map(ClientSecret::new), ) .set_redirect_uri(RedirectUrl::new(auth_state.redirect_uri).map_err(OidcError::from)?) @@ -138,7 +170,7 @@ pub async fn callback( let token_response = client .exchange_code(AuthorizationCode::new(query.code)) - .expect("valid code") + .map_err(OidcError::from)? // Set the PKCE code verifier. .set_pkce_verifier(PkceCodeVerifier::new(auth_state.pkce_verifier)) .request_async(&http_client) @@ -185,7 +217,7 @@ pub async fn callback( } else { // New user let mut federated_user: FederationBuilder = FederationBuilder::default(); - federated_user.idp_id(idp.id); + federated_user.idp_id(idp.id.clone()); federated_user.unique_id(mapped_user_data.unique_id.clone()); federated_user.protocols(vec![FederationProtocol { protocol_id: "oidc".into(), @@ -209,78 +241,33 @@ pub async fn callback( ) .await? }; - // TODO: Persist group memberships + let authed_info = AuthenticatedInfo::builder() + .user_id(user.id.clone()) + .user(user.clone()) + .methods(vec!["oidc".into()]) + .idp_id(idp.id.clone()) + .protocol_id("oidc".to_string()) + .build() + .map_err(AuthenticationError::from)?; - let (project, domain) = match &auth_state.scope { - Some(ProviderScope::Project(pid)) => ( - Some( - state - .provider - .get_resource_provider() - .get_project(&state.db, pid.as_ref()) - .await? - .ok_or_else(|| KeystoneApiError::NotFound { - resource: "project".into(), - identifier: pid.clone(), - })?, - ), - None, - ), - Some(ProviderScope::Domain(did)) => ( - None, - Some( - state - .provider - .get_resource_provider() - .get_domain(&state.db, did.as_ref()) - .await? - .ok_or_else(|| KeystoneApiError::NotFound { - resource: "domain".into(), - identifier: did.clone(), - })?, - ), - ), - _ => (None, None), - }; - let mut token = state.provider.get_token_provider().issue_token( - user.id.clone(), - Vec::from(["oidc".into()]), - Vec::::from([URL_SAFE - .encode(Uuid::new_v4().as_bytes()) - .trim_end_matches('=') - .to_string()]), - project.as_ref(), - domain.as_ref(), - )?; + // TODO: Persist group memberships - state - .provider - .get_token_provider() - .populate_role_assignments(&mut token, &state.db, &state.provider) - .await - .map_err(|_| KeystoneApiError::Forbidden)?; + let authz_info = get_authz_info(&state, auth_state.scope.as_ref()).await?; - state + let mut token = state .provider .get_token_provider() - .expand_project_information(&mut token, &state.db, &state.provider) - .await?; + .issue_token(authed_info, authz_info)?; - state + token = state .provider .get_token_provider() - .expand_domain_information(&mut token, &state.db, &state.provider) - .await?; + .expand_token_information(&token, &state.db, &state.provider) + .await + .map_err(|_| KeystoneApiError::Forbidden)?; let mut api_token = KeystoneTokenResponse { - token: ApiResponseToken::from_user_auth( - &state, - &token, - &user, - project.as_ref(), - domain.as_ref(), - ) - .await?, + token: ApiResponseToken::from_provider_token(&state, &token).await?, }; let catalog: Catalog = state .provider @@ -357,6 +344,7 @@ fn validate_bound_claims( Ok(()) } +/// Map the user data using the referred mapping fn map_user_data( idp: &ProviderIdentityProvider, mapping: &ProviderMapping, @@ -367,14 +355,14 @@ fn map_user_data( claims_as_json .get(&mapping.user_id_claim) .and_then(|x| x.as_str()) - .ok_or_else(|| OidcError::UserIdClaimMissing(mapping.user_id_claim.clone()))?, + .ok_or_else(|| OidcError::UserIdClaimRequired(mapping.user_id_claim.clone()))?, ); builder.user_name( claims_as_json .get(&mapping.user_name_claim) .and_then(|x| x.as_str()) - .ok_or_else(|| OidcError::UserNameClaimMissing(mapping.user_name_claim.clone()))?, + .ok_or_else(|| OidcError::UserNameClaimRequired(mapping.user_name_claim.clone()))?, ); builder.domain_id( diff --git a/src/api/v3/user/passkey/mod.rs b/src/api/v3/user/passkey/mod.rs index fd055868..31d539ed 100644 --- a/src/api/v3/user/passkey/mod.rs +++ b/src/api/v3/user/passkey/mod.rs @@ -18,7 +18,6 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use base64::{Engine as _, engine::general_purpose::URL_SAFE}; use serde_json::Value; use tracing::debug; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -28,6 +27,7 @@ use crate::api::{ error::{KeystoneApiError, WebauthnError}, v3::auth::token::types::Token as ApiToken, }; +use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo}; use crate::identity::IdentityApi; use crate::keystone::ServiceState; use crate::token::TokenApi; @@ -223,17 +223,17 @@ async fn login_finish( .delete_user_passkey_authentication_state(&state.db, &user_id) .await?; } + let authed_info = AuthenticatedInfo::builder() + .user_id(user_id.clone()) + .methods(vec!["passkey".into()]) + .build() + .map_err(AuthenticationError::from)?; + //.map_err(|e| OidcError::from(e))?; - let token = state.provider.get_token_provider().issue_token( - user_id, - vec!["passkey".into()], - Vec::::from([URL_SAFE - .encode(Uuid::new_v4().as_bytes()) - .trim_end_matches('=') - .to_string()]), - None, - None, - )?; + let token = state + .provider + .get_token_provider() + .issue_token(authed_info, AuthzInfo::Unscoped)?; let api_token = ApiToken::from_provider_token(&state, &token).await?; Ok(( diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 00000000..c29e1565 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,88 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::error; + +use crate::identity::types as identity_provider_types; +use crate::resource::types::{Domain, Project}; + +#[derive(Error, Debug)] +pub enum AuthenticationError { + #[error("building authentication information: {source}")] + AuthenticatedInfoBuilder { + #[from] + source: AuthenticatedInfoBuilderError, + }, + + #[error("The request you have made requires authentication.")] + Unauthorized, +} + +/// Information about successful authentication +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(setter(into, strip_option))] +pub struct AuthenticatedInfo { + pub user_id: String, + #[builder(default)] + pub user: Option, + #[builder(default)] + pub user_domain: Option, + #[builder(default)] + pub methods: Vec, + #[builder(default)] + pub audit_ids: Vec, + #[builder(default)] + pub idp_id: Option, + #[builder(default)] + pub protocol_id: Option, +} + +impl AuthenticatedInfo { + pub fn builder() -> AuthenticatedInfoBuilder { + AuthenticatedInfoBuilder::default() + } + + pub fn validate(&self) -> Result<(), AuthenticationError> { + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub enum AuthzInfo { + Unscoped, + Project(Project), + Domain(Domain), +} + +impl AuthzInfo { + pub fn validate(&self) -> Result<(), AuthenticationError> { + match self { + AuthzInfo::Unscoped => {} + AuthzInfo::Project(project) => { + if !project.enabled { + return Err(AuthenticationError::Unauthorized); + } + } + AuthzInfo::Domain(domain) => { + if !domain.enabled { + return Err(AuthenticationError::Unauthorized); + } + } + } + Ok(()) + } +} diff --git a/src/bin/keystone.rs b/src/bin/keystone.rs index 26a7926a..cddaaa1a 100644 --- a/src/bin/keystone.rs +++ b/src/bin/keystone.rs @@ -20,14 +20,16 @@ use sea_orm::Database; use std::io; use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; -use tokio::{net::TcpListener, signal}; +use std::time::Duration; +use tokio::{net::TcpListener, signal, spawn, time}; +use tokio_util::sync::CancellationToken; use tower::ServiceBuilder; use tower_http::{ LatencyUnit, ServiceBuilderExt, request_id::{MakeRequestId, PropagateRequestIdLayer, RequestId, SetRequestIdLayer}, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}, }; -use tracing::{Level, info_span}; +use tracing::{Level, error, info, info_span, trace}; use tracing_subscriber::{filter::LevelFilter, prelude::*}; use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; @@ -36,6 +38,7 @@ use uuid::Uuid; use openstack_keystone::api; use openstack_keystone::config::Config; +use openstack_keystone::federation::FederationApi; use openstack_keystone::keystone::{Service, ServiceState}; use openstack_keystone::plugin_manager::PluginManager; use openstack_keystone::provider::Provider; @@ -83,6 +86,9 @@ async fn main() -> Result<(), Report> { // build the tracing registry tracing_subscriber::registry().with(log_layer).init(); + let token = CancellationToken::new(); + let cloned_token = token.clone(); + let cfg = Config::new(args.config.into())?; let db_url = cfg.database.get_connection(); let mut opt = ConnectOptions::new(db_url.to_owned()); @@ -100,6 +106,8 @@ async fn main() -> Result<(), Report> { let shared_state = Arc::new(Service::new(cfg, conn, provider).unwrap()); + spawn(cleanup(cloned_token, shared_state.clone())); + let (router, api) = OpenApiRouter::with_openapi(api::ApiDoc::openapi()) .merge(api::openapi_router()) .split_for_parts(); @@ -154,9 +162,33 @@ async fn main() -> Result<(), Report> { let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 8080)); let listener = TcpListener::bind(&address).await?; - Ok(axum::serve(listener, app.into_make_service()) + axum::serve(listener, app.into_make_service()) .with_graceful_shutdown(shutdown_signal(shared_state)) - .await?) + .await?; + + token.cancel(); + Ok(()) +} + +/// Priodic cleanup job +async fn cleanup(cancel: CancellationToken, state: ServiceState) { + let mut interval = time::interval(Duration::from_secs(60)); + interval.tick().await; + info!("Start the periodic cleanup thread"); + loop { + tokio::select! { + _ = interval.tick() => { + trace!("cleanup job tick"); + if let Err(e) = state.provider.get_federation_provider().cleanup(&state.db).await { + error!("Error during cleanup job: {}", e); + } + }, + _ = cancel.cancelled() => { + info!("Cancellation requested. Stopping cleanup task."); + break; // Exit the loop + } + } + } } async fn shutdown_signal(state: ServiceState) { diff --git a/src/db/entity/federated_auth_state.rs b/src/db/entity/federated_auth_state.rs index 88d4f02f..c02e182a 100644 --- a/src/db/entity/federated_auth_state.rs +++ b/src/db/entity/federated_auth_state.rs @@ -12,7 +12,7 @@ pub struct Model { pub nonce: String, pub redirect_uri: String, pub pkce_verifier: String, - pub started_at: DateTime, + pub expires_at: DateTime, pub requested_scope: Option, } diff --git a/src/federation/backends/sql.rs b/src/federation/backends/sql.rs index 487be950..b12b7868 100644 --- a/src/federation/backends/sql.rs +++ b/src/federation/backends/sql.rs @@ -172,6 +172,14 @@ impl FederationBackend for SqlBackend { .await .map_err(FederationProviderError::database) } + + /// Cleanup expired resources + #[tracing::instrument(level = "debug", skip(self, db))] + async fn cleanup(&self, db: &DatabaseConnection) -> Result<(), FederationProviderError> { + auth_state::delete_expired(&self.config, db) + .await + .map_err(FederationProviderError::database) + } } #[cfg(test)] diff --git a/src/federation/backends/sql/auth_state.rs b/src/federation/backends/sql/auth_state.rs index 02ff5038..429452c1 100644 --- a/src/federation/backends/sql/auth_state.rs +++ b/src/federation/backends/sql/auth_state.rs @@ -12,6 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 +use chrono::Utc; use sea_orm::DatabaseConnection; use sea_orm::entity::*; use sea_orm::query::*; @@ -52,7 +53,7 @@ pub async fn create( nonce: Set(rec.nonce.clone()), redirect_uri: Set(rec.redirect_uri.clone()), pkce_verifier: Set(rec.pkce_verifier.clone()), - started_at: Set(rec.started_at.naive_utc()), + expires_at: Set(rec.expires_at.naive_utc()), requested_scope: scope.map(Set).unwrap_or(NotSet).into(), }; @@ -78,6 +79,17 @@ pub async fn delete>( } } +pub async fn delete_expired( + _conf: &Config, + db: &DatabaseConnection, +) -> Result<(), FederationDatabaseError> { + DbFederatedAuthState::delete_many() + .filter(db_federated_auth_state::Column::ExpiresAt.lt(Utc::now())) + .exec(db) + .await?; + Ok(()) +} + impl TryFrom for AuthState { type Error = FederationDatabaseError; @@ -89,7 +101,7 @@ impl TryFrom for AuthState { builder.mapping_id(value.mapping_id.clone()); builder.redirect_uri(value.redirect_uri.clone()); builder.pkce_verifier(value.pkce_verifier.clone()); - builder.started_at(value.started_at.and_utc()); + builder.expires_at(value.expires_at.and_utc()); if let Some(scope) = value.requested_scope { builder.scope(serde_json::from_value::(scope)?); } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 237f7900..2077dbb7 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -114,6 +114,9 @@ pub trait FederationApi: Send + Sync + Clone { db: &DatabaseConnection, id: &'a str, ) -> Result<(), FederationProviderError>; + + /// Cleanup expired resources + async fn cleanup(&self, db: &DatabaseConnection) -> Result<(), FederationProviderError>; } #[cfg(test)] @@ -207,6 +210,12 @@ mock! { db: &DatabaseConnection, id: &'a str, ) -> Result<(), FederationProviderError>; + + async fn cleanup( + &self, + db: &DatabaseConnection, + ) -> Result<(), FederationProviderError>; + } impl Clone for FederationProvider { @@ -384,4 +393,10 @@ impl FederationApi for FederationProvider { ) -> Result<(), FederationProviderError> { self.backend_driver.delete_auth_state(db, id).await } + + /// Cleanup expired resources + #[tracing::instrument(level = "info", skip(self, db))] + async fn cleanup(&self, db: &DatabaseConnection) -> Result<(), FederationProviderError> { + self.backend_driver.cleanup(db).await + } } diff --git a/src/federation/types.rs b/src/federation/types.rs index c192669d..af385418 100644 --- a/src/federation/types.rs +++ b/src/federation/types.rs @@ -124,6 +124,9 @@ pub trait FederationBackend: DynClone + Send + Sync + std::fmt::Debug { db: &DatabaseConnection, id: &'a str, ) -> Result<(), FederationProviderError>; + + /// Cleanup expired resources + async fn cleanup(&self, db: &DatabaseConnection) -> Result<(), FederationProviderError>; } dyn_clone::clone_trait_object!(FederationBackend); diff --git a/src/federation/types/auth_state.rs b/src/federation/types/auth_state.rs index f7c39e4e..c0baabfd 100644 --- a/src/federation/types/auth_state.rs +++ b/src/federation/types/auth_state.rs @@ -16,6 +16,10 @@ use chrono::{DateTime, Utc}; use derive_builder::Builder; use serde::{Deserialize, Serialize}; +use crate::api::types::{ + Domain as ApiDomain, ProjectScope as ApiProjectScope, Scope as ApiScope, System as ApiSystem, +}; + #[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, PartialEq)] #[builder(setter(strip_option, into))] pub struct AuthState { @@ -37,19 +41,132 @@ pub struct AuthState { /// PKCE verifier value pub pkce_verifier: String, - /// Timestamp when the auth was initiated + /// Timestamp when the auth will expire #[builder(default)] - pub started_at: DateTime, + pub expires_at: DateTime, /// Requested scope #[builder(default)] pub scope: Option, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +//#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +//#[serde(rename_all = "lowercase")] +//pub enum Scope { +// Project(String), +// Domain(String), +// System(String), +//} +/// The authorization scope, including the system (Since v3.10), a project, or a domain (Since +/// v3.4). If multiple scopes are specified in the same request (e.g. project and domain or domain +/// and system) an HTTP 400 Bad Request will be returned, as a token cannot be simultaneously +/// scoped to multiple authorization targets. An ID is sufficient to uniquely identify a project +/// but if a project is specified by name, then the domain of the project must also be specified in +/// order to uniquely identify the project by name. A domain scope may be specified by either the +/// domain’s ID or name with equivalent results. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Scope { - Project(String), - Domain(String), - System(String), + /// Project scope + Project(Project), + /// Domain scope + Domain(Domain), + /// System scope + System(System), +} + +/// Project scope information +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct Project { + /// Project ID + pub id: Option, + /// Project Name + pub name: Option, + /// project domain + pub domain: Option, +} + +/// Domain information +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(setter(into))] +pub struct Domain { + /// Domain ID + #[builder(default)] + pub id: Option, + /// Domain Name + #[builder(default)] + pub name: Option, +} + +/// System scope +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(setter(into))] +pub struct System { + /// system scope + #[builder(default)] + pub all: Option, +} + +impl From for Domain { + fn from(value: ApiDomain) -> Self { + Self { + id: value.id, + name: value.name, + } + } +} + +impl From for ApiDomain { + fn from(value: Domain) -> Self { + Self { + id: value.id, + name: value.name, + } + } +} + +impl From for Project { + fn from(value: ApiProjectScope) -> Self { + Self { + id: value.id, + name: value.name, + domain: value.domain.map(Into::into), + } + } +} + +impl From for ApiProjectScope { + fn from(value: Project) -> Self { + Self { + id: value.id, + name: value.name, + domain: value.domain.map(Into::into), + } + } +} + +impl From<&Project> for ApiProjectScope { + fn from(value: &Project) -> Self { + Self { + id: value.id.clone(), + name: value.name.clone(), + domain: value.domain.clone().map(Into::into), + } + } +} + +impl From for System { + fn from(value: ApiSystem) -> Self { + Self { all: value.all } + } +} + +impl From for Scope { + fn from(value: ApiScope) -> Self { + match value { + ApiScope::Project(scope) => Scope::Project(scope.into()), + ApiScope::Domain(scope) => Scope::Domain(scope.into()), + ApiScope::System(scope) => Scope::System(scope.into()), + } + } } diff --git a/src/identity/backends/sql.rs b/src/identity/backends/sql.rs index cb9fb9b3..d179bb9b 100644 --- a/src/identity/backends/sql.rs +++ b/src/identity/backends/sql.rs @@ -29,6 +29,7 @@ mod user; mod user_option; use super::super::types::*; +use crate::auth::{AuthenticatedInfo, AuthenticationError}; use crate::config::Config; use crate::db::entity::{ federated_user as db_federated_user, local_user as db_local_user, @@ -59,7 +60,7 @@ impl IdentityBackend for SqlBackend { &self, db: &DatabaseConnection, auth: UserPasswordAuthRequest, - ) -> Result { + ) -> Result { let user_with_passwords = local_user::load_local_user_with_passwords( db, auth.id, @@ -79,6 +80,14 @@ impl IdentityBackend for SqlBackend { expected_hash, )? { if let Some(user) = user::get(db, &local_user.user_id).await? { + // TODO: Check user is locked + // TODO: Check password is expired + // TODO: reset failed login attempt + if !user.enabled.is_some_and(|val| val) { + return Err(IdentityProviderError::UserDisabled( + local_user.user_id, + )); + } let user_builder = common::get_local_user_builder( &self.config, &user, @@ -86,7 +95,13 @@ impl IdentityBackend for SqlBackend { Some(passwords), user_opts, ); - return Ok(user_builder.build()?); + let user = user_builder.build()?; + return Ok(AuthenticatedInfo::builder() + .user_id(user.id.clone()) + .user(user) + .methods(vec!["password".into()]) + .build() + .map_err(AuthenticationError::from)?); } } else { return Err(IdentityProviderError::WrongUsernamePassword); diff --git a/src/identity/error.rs b/src/identity/error.rs index f5feb8e3..402c6c6b 100644 --- a/src/identity/error.rs +++ b/src/identity/error.rs @@ -38,6 +38,9 @@ pub enum IdentityProviderError { #[error("group {0} not found")] GroupNotFound(String), + #[error("The account is disabled for user: {0}")] + UserDisabled(String), + /// Identity provider error #[error(transparent)] IdentityDatabase { @@ -78,6 +81,12 @@ pub enum IdentityProviderError { source: IdentityProviderPasswordHashError, }, + #[error(transparent)] + AuthenticationInfo { + #[from] + source: crate::auth::AuthenticationError, + }, + #[error(transparent)] ResourceProvider { #[from] diff --git a/src/identity/mod.rs b/src/identity/mod.rs index a14670d0..83fd1cb3 100644 --- a/src/identity/mod.rs +++ b/src/identity/mod.rs @@ -24,6 +24,7 @@ pub mod error; pub mod password_hashing; pub(crate) mod types; +use crate::auth::AuthenticatedInfo; use crate::config::Config; use crate::identity::backends::sql::SqlBackend; use crate::identity::error::IdentityProviderError; @@ -47,7 +48,7 @@ pub trait IdentityApi: Send + Sync + Clone { db: &DatabaseConnection, provider: &Provider, auth: UserPasswordAuthRequest, - ) -> Result; + ) -> Result; async fn list_users( &self, @@ -178,7 +179,7 @@ mock! { db: &DatabaseConnection, provider: &Provider, auth: UserPasswordAuthRequest, - ) -> Result; + ) -> Result; async fn list_users( &self, @@ -332,7 +333,7 @@ impl IdentityApi for IdentityProvider { db: &DatabaseConnection, provider: &Provider, auth: UserPasswordAuthRequest, - ) -> Result { + ) -> Result { let mut auth = auth; if auth.id.is_none() { if auth.name.is_none() { diff --git a/src/identity/types.rs b/src/identity/types.rs index 29fe47e6..da701d72 100644 --- a/src/identity/types.rs +++ b/src/identity/types.rs @@ -23,6 +23,7 @@ use webauthn_rs::prelude::{Passkey, PasskeyAuthentication, PasskeyRegistration}; use crate::config::Config; use crate::identity::IdentityProviderError; +use crate::auth::AuthenticatedInfo; pub use crate::identity::types::group::{Group, GroupCreate, GroupListParameters}; pub use crate::identity::types::user::*; //pub use crate::identity::types::user::{ @@ -41,7 +42,7 @@ pub trait IdentityBackend: DynClone + Send + Sync + std::fmt::Debug { &self, db: &DatabaseConnection, auth: UserPasswordAuthRequest, - ) -> Result; + ) -> Result; /// List Users async fn list_users( diff --git a/src/keystone.rs b/src/keystone.rs index ffb1621d..1bb206ff 100644 --- a/src/keystone.rs +++ b/src/keystone.rs @@ -32,6 +32,8 @@ pub struct Service { #[from_ref(skip)] pub db: DatabaseConnection, pub webauthn: Webauthn, + + pub shutdown: bool, } pub type ServiceState = Arc; @@ -62,6 +64,7 @@ impl Service { provider, db, webauthn, + shutdown: false, }) } diff --git a/src/lib.rs b/src/lib.rs index 2c999c1a..5c9d2c68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod api; pub mod assignment; +pub mod auth; pub mod catalog; pub mod config; pub mod db; diff --git a/src/token/application_credential.rs b/src/token/application_credential.rs index ad1280c5..eb756594 100644 --- a/src/token/application_credential.rs +++ b/src/token/application_credential.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use std::io::Write; use crate::assignment::types::Role; +use crate::identity::types::UserResponse; use crate::resource::types::Project; use crate::token::{ error::TokenProviderError, @@ -28,6 +29,7 @@ use crate::token::{ }; #[derive(Builder, Clone, Debug, Default, PartialEq)] +#[builder(setter(into))] pub struct ApplicationCredentialPayload { pub user_id: String, #[builder(default, setter(name = _methods))] @@ -38,6 +40,8 @@ pub struct ApplicationCredentialPayload { pub project_id: String, pub application_credential_id: String, + #[builder(default)] + pub user: Option, #[builder(default)] pub roles: Vec, #[builder(default)] diff --git a/src/token/domain_scoped.rs b/src/token/domain_scoped.rs index eb6e3436..a2e00e7d 100644 --- a/src/token/domain_scoped.rs +++ b/src/token/domain_scoped.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use std::io::Write; use crate::assignment::types::Role; +use crate::identity::types::UserResponse; use crate::resource::types::Domain; use crate::token::{ error::TokenProviderError, @@ -28,7 +29,7 @@ use crate::token::{ }; #[derive(Builder, Clone, Debug, Default, PartialEq)] -#[builder(setter(strip_option, into))] +#[builder(setter(into))] pub struct DomainScopePayload { pub user_id: String, #[builder(default, setter(name = _methods))] @@ -39,7 +40,9 @@ pub struct DomainScopePayload { pub domain_id: String, #[builder(default)] - pub roles: Vec, + pub user: Option, + #[builder(default)] + pub roles: Option>, #[builder(default)] pub domain: Option, } diff --git a/src/token/error.rs b/src/token/error.rs index 1652955a..6db82ec4 100644 --- a/src/token/error.rs +++ b/src/token/error.rs @@ -116,6 +116,27 @@ pub enum TokenProviderError { source: crate::token::domain_scoped::DomainScopePayloadBuilderError, }, + #[error(transparent)] + FederationUnscopedBuilder { + /// The source of the error. + #[from] + source: crate::token::federation_unscoped::FederationUnscopedPayloadBuilderError, + }, + + #[error(transparent)] + FederationProjectScopeBuilder { + /// The source of the error. + #[from] + source: crate::token::federation_project_scoped::FederationProjectScopePayloadBuilderError, + }, + + #[error(transparent)] + FederationDomainScopeBuilder { + /// The source of the error. + #[from] + source: crate::token::federation_domain_scoped::FederationDomainScopePayloadBuilderError, + }, + #[error(transparent)] AssignmentProvider { /// The source of the error. @@ -123,6 +144,12 @@ pub enum TokenProviderError { source: crate::assignment::error::AssignmentProviderError, }, + #[error(transparent)] + AuthenticationInfo { + #[from] + source: crate::auth::AuthenticationError, + }, + #[error(transparent)] ResourceProvider { /// The source of the error. @@ -132,4 +159,7 @@ pub enum TokenProviderError { #[error("actor has no roles on scope")] ActorHasNoRolesOnTarget, + + #[error("federated payload must contain idp_id and protocol_id")] + FederatedPayloadMissingData, } diff --git a/src/token/federation_domain_scoped.rs b/src/token/federation_domain_scoped.rs index d01fbf19..108b5c7c 100644 --- a/src/token/federation_domain_scoped.rs +++ b/src/token/federation_domain_scoped.rs @@ -18,6 +18,9 @@ use rmp::{decode::read_pfix, encode::write_pfix}; use std::collections::BTreeMap; use std::io::Write; +use crate::assignment::types::Role; +use crate::identity::types::UserResponse; +use crate::resource::types::Domain; use crate::token::{ error::TokenProviderError, fernet::{self, MsgPackToken}, @@ -27,7 +30,7 @@ use crate::token::{ /// Federated domain scope token payload #[derive(Builder, Clone, Debug, Default, PartialEq)] -#[builder(setter(strip_option, into))] +#[builder(setter(into))] pub struct FederationDomainScopePayload { pub user_id: String, #[builder(default, setter(name = _methods))] @@ -39,6 +42,13 @@ pub struct FederationDomainScopePayload { pub idp_id: String, pub protocol_id: String, pub group_ids: Vec, + + #[builder(default)] + pub user: Option, + #[builder(default)] + pub roles: Option>, + #[builder(default)] + pub domain: Option, } impl FederationDomainScopePayloadBuilder { @@ -120,6 +130,7 @@ impl MsgPackToken for FederationDomainScopePayload { group_ids: group_ids.into_iter().collect(), idp_id, protocol_id, + ..Default::default() }) } } @@ -142,6 +153,7 @@ mod tests { group_ids: vec!["g1".into()], idp_id: "idp_id".into(), protocol_id: "proto".into(), + ..Default::default() }; let auth_map = BTreeMap::from([(1, "oidc".into())]); let mut buf = vec![]; diff --git a/src/token/federation_project_scoped.rs b/src/token/federation_project_scoped.rs index 760ef7ff..9487b096 100644 --- a/src/token/federation_project_scoped.rs +++ b/src/token/federation_project_scoped.rs @@ -18,6 +18,9 @@ use rmp::{decode::read_pfix, encode::write_pfix}; use std::collections::BTreeMap; use std::io::Write; +use crate::assignment::types::Role; +use crate::identity::types::UserResponse; +use crate::resource::types::Project; use crate::token::{ error::TokenProviderError, fernet::{self, MsgPackToken}, @@ -27,7 +30,7 @@ use crate::token::{ /// Federated project scope token payload #[derive(Builder, Clone, Debug, Default, PartialEq)] -#[builder(setter(strip_option, into))] +#[builder(setter(into))] pub struct FederationProjectScopePayload { pub user_id: String, #[builder(default, setter(name = _methods))] @@ -39,6 +42,13 @@ pub struct FederationProjectScopePayload { pub idp_id: String, pub protocol_id: String, pub group_ids: Vec, + + #[builder(default)] + pub user: Option, + #[builder(default)] + pub roles: Option>, + #[builder(default)] + pub project: Option, } impl FederationProjectScopePayloadBuilder { @@ -120,6 +130,7 @@ impl MsgPackToken for FederationProjectScopePayload { group_ids: group_ids.into_iter().collect(), idp_id, protocol_id, + ..Default::default() }) } } @@ -142,6 +153,7 @@ mod tests { group_ids: vec!["g1".into()], idp_id: "idp_id".into(), protocol_id: "proto".into(), + ..Default::default() }; let auth_map = BTreeMap::from([(1, "oidc".into())]); let mut buf = vec![]; diff --git a/src/token/federation_unscoped.rs b/src/token/federation_unscoped.rs index 518c1794..4d11b1c1 100644 --- a/src/token/federation_unscoped.rs +++ b/src/token/federation_unscoped.rs @@ -18,6 +18,7 @@ use rmp::{decode::read_pfix, encode::write_pfix}; use std::collections::BTreeMap; use std::io::Write; +use crate::identity::types::UserResponse; use crate::token::{ error::TokenProviderError, fernet::{self, MsgPackToken}, @@ -27,7 +28,7 @@ use crate::token::{ /// Federated unscoped token payload #[derive(Builder, Clone, Debug, Default, PartialEq)] -#[builder(setter(strip_option, into))] +#[builder(setter(into))] pub struct FederationUnscopedPayload { pub user_id: String, #[builder(default, setter(name = _methods))] @@ -38,6 +39,9 @@ pub struct FederationUnscopedPayload { pub idp_id: String, pub protocol_id: String, pub group_ids: Vec, + + #[builder(default)] + pub user: Option, } impl FederationUnscopedPayloadBuilder { @@ -116,6 +120,7 @@ impl MsgPackToken for FederationUnscopedPayload { group_ids: group_ids.into_iter().collect(), idp_id, protocol_id, + ..Default::default() }) } } @@ -137,6 +142,7 @@ mod tests { group_ids: vec!["g1".into()], idp_id: "idp_id".into(), protocol_id: "proto".into(), + ..Default::default() }; let auth_map = BTreeMap::from([(1, "oidc".into())]); let mut buf = vec![]; diff --git a/src/token/fernet.rs b/src/token/fernet.rs index 31d42cc9..a7ea9eb6 100644 --- a/src/token/fernet.rs +++ b/src/token/fernet.rs @@ -319,6 +319,7 @@ pub(super) mod tests { methods: vec!["password".into()], audit_ids: vec!["Zm9vCg".into()], expires_at: Local::now().trunc_subsecs(0).into(), + ..Default::default() }); let mut backend = FernetTokenProvider::default(); @@ -458,6 +459,7 @@ pub(super) mod tests { audit_ids: vec!["Zm9vCg".into()], expires_at: Local::now().trunc_subsecs(0).into(), + ..Default::default() }); let mut backend = FernetTokenProvider::default(); @@ -510,6 +512,7 @@ pub(super) mod tests { protocol_id: "proto".into(), audit_ids: vec!["Zm9vCg".into()], expires_at: Local::now().trunc_subsecs(0).into(), + ..Default::default() }); let mut backend = FernetTokenProvider::default(); @@ -562,6 +565,7 @@ pub(super) mod tests { protocol_id: "proto".into(), audit_ids: vec!["Zm9vCg".into()], expires_at: Local::now().trunc_subsecs(0).into(), + ..Default::default() }); let mut backend = FernetTokenProvider::default(); diff --git a/src/token/mod.rs b/src/token/mod.rs index 7dd0a241..9d7978cb 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -13,10 +13,12 @@ // SPDX-License-Identifier: Apache-2.0 use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; use chrono::{Local, TimeDelta}; #[cfg(test)] use mockall::mock; use sea_orm::DatabaseConnection; +use uuid::Uuid; pub mod application_credential; pub mod domain_scoped; @@ -35,6 +37,7 @@ use crate::assignment::{ error::AssignmentProviderError, types::{Role, RoleAssignmentListParametersBuilder}, }; +use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo}; use crate::config::{Config, TokenProvider as TokenProviderType}; use crate::provider::Provider; use crate::resource::{ @@ -46,6 +49,13 @@ use types::TokenBackend; pub use application_credential::ApplicationCredentialPayload; pub use domain_scoped::{DomainScopePayload, DomainScopePayloadBuilder}; +pub use federation_domain_scoped::{ + FederationDomainScopePayload, FederationDomainScopePayloadBuilder, +}; +pub use federation_project_scoped::{ + FederationProjectScopePayload, FederationProjectScopePayloadBuilder, +}; +pub use federation_unscoped::{FederationUnscopedPayload, FederationUnscopedPayloadBuilder}; pub use project_scoped::{ProjectScopePayload, ProjectScopePayloadBuilder}; pub use types::Token; pub use unscoped::{UnscopedPayload, UnscopedPayloadBuilder}; @@ -67,10 +77,183 @@ impl TokenProvider { backend_driver: Box::new(backend_driver), }) } + + fn create_unscoped_token( + &self, + authentication_info: &AuthenticatedInfo, + ) -> Result { + Ok(Token::Unscoped( + UnscopedPayloadBuilder::default() + .user_id(authentication_info.user_id.clone()) + .user(authentication_info.user.clone()) + .methods(authentication_info.methods.clone().iter()) + .audit_ids(authentication_info.audit_ids.clone().iter()) + .expires_at( + Local::now() + .to_utc() + .checked_add_signed(TimeDelta::seconds(self.config.token.expiration as i64)) + .ok_or(TokenProviderError::ExpiryCalculation)?, + ) + .build()?, + )) + } + + fn create_project_scope_token( + &self, + authentication_info: &AuthenticatedInfo, + project: &Project, + ) -> Result { + Ok(Token::ProjectScope( + ProjectScopePayloadBuilder::default() + .user_id(authentication_info.user_id.clone()) + .user(authentication_info.user.clone()) + .methods(authentication_info.methods.clone().iter()) + .audit_ids(authentication_info.audit_ids.clone().iter()) + .expires_at( + Local::now() + .to_utc() + .checked_add_signed(TimeDelta::seconds(self.config.token.expiration as i64)) + .ok_or(TokenProviderError::ExpiryCalculation)?, + ) + .project_id(project.id.clone()) + .project(project.clone()) + .build()?, + )) + } + + fn create_domain_scope_token( + &self, + authentication_info: &AuthenticatedInfo, + domain: &Domain, + ) -> Result { + Ok(Token::DomainScope( + DomainScopePayloadBuilder::default() + .user_id(authentication_info.user_id.clone()) + .user(authentication_info.user.clone()) + .methods(authentication_info.methods.clone().iter()) + .audit_ids(authentication_info.audit_ids.clone().iter()) + .expires_at( + Local::now() + .to_utc() + .checked_add_signed(TimeDelta::seconds(self.config.token.expiration as i64)) + .ok_or(TokenProviderError::ExpiryCalculation)?, + ) + .domain_id(domain.id.clone()) + .domain(domain.clone()) + .build()?, + )) + } + + fn create_federated_unscoped_token( + &self, + authentication_info: &AuthenticatedInfo, + ) -> Result { + if let (Some(idp_id), Some(protocol_id)) = ( + authentication_info.idp_id.clone(), + authentication_info.protocol_id.clone(), + ) { + Ok(Token::FederationUnscoped( + FederationUnscopedPayloadBuilder::default() + .user_id(authentication_info.user_id.clone()) + .user(authentication_info.user.clone()) + .methods(authentication_info.methods.clone().iter()) + .audit_ids(authentication_info.audit_ids.clone().iter()) + .expires_at( + Local::now() + .to_utc() + .checked_add_signed(TimeDelta::seconds( + self.config.token.expiration as i64, + )) + .ok_or(TokenProviderError::ExpiryCalculation)?, + ) + .idp_id(idp_id) + .protocol_id(protocol_id) + .build()?, + )) + } else { + Err(TokenProviderError::FederatedPayloadMissingData) + } + } + + fn create_federated_project_scope_token( + &self, + authentication_info: &AuthenticatedInfo, + project: &Project, + ) -> Result { + if let (Some(idp_id), Some(protocol_id)) = ( + authentication_info.idp_id.clone(), + authentication_info.protocol_id.clone(), + ) { + Ok(Token::FederationProjectScope( + FederationProjectScopePayloadBuilder::default() + .user_id(authentication_info.user_id.clone()) + .user(authentication_info.user.clone()) + .methods(authentication_info.methods.clone().iter()) + .audit_ids(authentication_info.audit_ids.clone().iter()) + .expires_at( + Local::now() + .to_utc() + .checked_add_signed(TimeDelta::seconds( + self.config.token.expiration as i64, + )) + .ok_or(TokenProviderError::ExpiryCalculation)?, + ) + .idp_id(idp_id) + .protocol_id(protocol_id) + .group_ids(vec![]) + .project_id(project.id.clone()) + .project(project.clone()) + .build()?, + )) + } else { + Err(TokenProviderError::FederatedPayloadMissingData) + } + } + + fn create_federated_domain_scope_token( + &self, + authentication_info: &AuthenticatedInfo, + domain: &Domain, + ) -> Result { + if let (Some(idp_id), Some(protocol_id)) = ( + authentication_info.idp_id.clone(), + authentication_info.protocol_id.clone(), + ) { + Ok(Token::FederationDomainScope( + FederationDomainScopePayloadBuilder::default() + .user_id(authentication_info.user_id.clone()) + .user(authentication_info.user.clone()) + .methods(authentication_info.methods.clone().iter()) + .audit_ids(authentication_info.audit_ids.clone().iter()) + .expires_at( + Local::now() + .to_utc() + .checked_add_signed(TimeDelta::seconds( + self.config.token.expiration as i64, + )) + .ok_or(TokenProviderError::ExpiryCalculation)?, + ) + .idp_id(idp_id) + .protocol_id(protocol_id) + .domain_id(domain.id.clone()) + .domain(domain.clone()) + .build()?, + )) + } else { + Err(TokenProviderError::FederatedPayloadMissingData) + } + } } #[async_trait] pub trait TokenApi: Send + Sync + Clone { + async fn authenticate_by_token<'a>( + &self, + credential: &'a str, + allow_expired: Option, + window_seconds: Option, + ) -> Result; + /// Validate the token async fn validate_token<'a>( &self, @@ -80,16 +263,11 @@ pub trait TokenApi: Send + Sync + Clone { ) -> Result; /// Issue a token for given parameters - fn issue_token( + fn issue_token( &self, - user_id: U, - methods: Vec, - audit_ids: Vec, - project: Option<&Project>, - domain: Option<&Domain>, - ) -> Result - where - U: AsRef; + authentication_info: AuthenticatedInfo, + authz_info: AuthzInfo, + ) -> Result; /// Encode the token into the X-SubjectToken String fn encode_token(&self, token: &Token) -> Result; @@ -102,25 +280,37 @@ pub trait TokenApi: Send + Sync + Clone { provider: &Provider, ) -> Result<(), TokenProviderError>; - /// Populate Project information in the token that support that information - async fn expand_project_information( + /// Populate additional information (project, domain, roles, etc) in the token that support + /// that information + async fn expand_token_information( &self, - token: &mut Token, - db: &DatabaseConnection, - provider: &Provider, - ) -> Result<(), TokenProviderError>; - - /// Populate Domain information in the token that support that information - async fn expand_domain_information( - &self, - token: &mut Token, + token: &Token, db: &DatabaseConnection, provider: &Provider, - ) -> Result<(), TokenProviderError>; + ) -> Result; } #[async_trait] impl TokenApi for TokenProvider { + /// Authenticate by token + #[tracing::instrument(level = "info", skip(self, credential))] + async fn authenticate_by_token<'a>( + &self, + credential: &'a str, + allow_expired: Option, + window_seconds: Option, + ) -> Result { + let token = self + .validate_token(credential, allow_expired, window_seconds) + .await?; + Ok(AuthenticatedInfo::builder() + .user_id(token.user_id()) + .methods(token.methods().clone()) + .audit_ids(token.audit_ids().clone()) + .build() + .map_err(AuthenticationError::from)?) + } + /// Validate token #[tracing::instrument(level = "info", skip(self, credential))] async fn validate_token<'a>( @@ -142,71 +332,41 @@ impl TokenApi for TokenProvider { Ok(token) } - fn issue_token( + fn issue_token( &self, - user_id: U, - methods: Vec, - audit_ids: Vec, - project: Option<&Project>, - domain: Option<&Domain>, - ) -> Result - where - U: AsRef, - { - let token = if let Some(project) = project { - Token::ProjectScope( - ProjectScopePayloadBuilder::default() - .user_id(user_id.as_ref()) - .methods(methods.into_iter()) - .audit_ids(audit_ids.into_iter()) - .expires_at( - Local::now() - .to_utc() - .checked_add_signed(TimeDelta::seconds( - self.config.token.expiration as i64, - )) - .ok_or(TokenProviderError::ExpiryCalculation)?, - ) - .project_id(project.id.clone()) - .project(project.clone()) - .build()?, - ) - } else if let Some(domain) = domain { - Token::DomainScope( - DomainScopePayloadBuilder::default() - .user_id(user_id.as_ref()) - .methods(methods.into_iter()) - .audit_ids(audit_ids.into_iter()) - .expires_at( - Local::now() - .to_utc() - .checked_add_signed(TimeDelta::seconds( - self.config.token.expiration as i64, - )) - .ok_or(TokenProviderError::ExpiryCalculation)?, - ) - .domain_id(domain.id.clone()) - .domain(domain.clone()) - .build()?, - ) + authentication_info: AuthenticatedInfo, + authz_info: AuthzInfo, + ) -> Result { + // TODO: Check whether it is allowed to change the scope of the token if AuthenticatedInfo + // already contains scope it was issued for. + let mut authentication_info = authentication_info; + authentication_info.audit_ids.push( + URL_SAFE + .encode(Uuid::new_v4().as_bytes()) + .trim_end_matches('=') + .to_string(), + ); + if authentication_info.idp_id.is_some() && authentication_info.protocol_id.is_some() { + match &authz_info { + AuthzInfo::Project(project) => { + self.create_federated_project_scope_token(&authentication_info, project) + } + AuthzInfo::Domain(domain) => { + self.create_federated_domain_scope_token(&authentication_info, domain) + } + AuthzInfo::Unscoped => self.create_federated_unscoped_token(&authentication_info), + } } else { - Token::Unscoped( - UnscopedPayloadBuilder::default() - .user_id(user_id.as_ref()) - .methods(methods.into_iter()) - .audit_ids(audit_ids.into_iter()) - .expires_at( - Local::now() - .to_utc() - .checked_add_signed(TimeDelta::seconds( - self.config.token.expiration as i64, - )) - .ok_or(TokenProviderError::ExpiryCalculation)?, - ) - .build()?, - ) - }; - Ok(token) + match &authz_info { + AuthzInfo::Project(project) => { + self.create_project_scope_token(&authentication_info, project) + } + AuthzInfo::Domain(domain) => { + self.create_domain_scope_token(&authentication_info, domain) + } + AuthzInfo::Unscoped => self.create_unscoped_token(&authentication_info), + } + } } /// Validate token @@ -223,50 +383,58 @@ impl TokenApi for TokenProvider { ) -> Result<(), TokenProviderError> { match token { Token::ProjectScope(data) => { - data.roles = provider - .get_assignment_provider() - .list_role_assignments( - db, - provider, - &RoleAssignmentListParametersBuilder::default() - .user_id(&data.user_id) - .project_id(&data.project_id) - .build() - .map_err(AssignmentProviderError::from)?, - ) - .await? - .into_iter() - .map(|x| Role { - id: x.role_id.clone(), - name: x.role_name.clone().unwrap_or_default(), - ..Default::default() - }) - .collect(); - if data.roles.is_empty() { + data.roles = Some( + provider + .get_assignment_provider() + .list_role_assignments( + db, + provider, + &RoleAssignmentListParametersBuilder::default() + .user_id(&data.user_id) + .project_id(&data.project_id) + .include_names(true) + .effective(true) + .build() + .map_err(AssignmentProviderError::from)?, + ) + .await? + .into_iter() + .map(|x| Role { + id: x.role_id.clone(), + name: x.role_name.clone().unwrap_or_default(), + ..Default::default() + }) + .collect(), + ); + if data.roles.as_ref().is_none_or(|roles| roles.is_empty()) { return Err(TokenProviderError::ActorHasNoRolesOnTarget); } } Token::DomainScope(data) => { - data.roles = provider - .get_assignment_provider() - .list_role_assignments( - db, - provider, - &RoleAssignmentListParametersBuilder::default() - .user_id(&data.user_id) - .domain_id(&data.domain_id) - .build() - .map_err(AssignmentProviderError::from)?, - ) - .await? - .into_iter() - .map(|x| Role { - id: x.role_id.clone(), - name: x.role_name.clone().unwrap_or_default(), - ..Default::default() - }) - .collect(); - if data.roles.is_empty() { + data.roles = Some( + provider + .get_assignment_provider() + .list_role_assignments( + db, + provider, + &RoleAssignmentListParametersBuilder::default() + .user_id(&data.user_id) + .domain_id(&data.domain_id) + .include_names(true) + .effective(true) + .build() + .map_err(AssignmentProviderError::from)?, + ) + .await? + .into_iter() + .map(|x| Role { + id: x.role_id.clone(), + name: x.role_name.clone().unwrap_or_default(), + ..Default::default() + }) + .collect(), + ); + if data.roles.as_ref().is_none_or(|roles| roles.is_empty()) { return Err(TokenProviderError::ActorHasNoRolesOnTarget); } } @@ -279,6 +447,8 @@ impl TokenApi for TokenProvider { &RoleAssignmentListParametersBuilder::default() .user_id(&data.user_id) .project_id(&data.project_id) + .include_names(true) + .effective(true) .build() .map_err(AssignmentProviderError::from)?, ) @@ -300,14 +470,15 @@ impl TokenApi for TokenProvider { Ok(()) } - async fn expand_project_information( + async fn expand_token_information( &self, - token: &mut Token, + token: &Token, db: &DatabaseConnection, provider: &Provider, - ) -> Result<(), TokenProviderError> { - match token { - Token::ProjectScope(data) => { + ) -> Result { + let mut new_token = token.clone(); + match new_token { + Token::ProjectScope(ref mut data) => { if data.project.is_none() { let project = provider .get_resource_provider() @@ -317,7 +488,7 @@ impl TokenApi for TokenProvider { data.project = project; } } - Token::ApplicationCredential(data) => { + Token::ApplicationCredential(ref mut data) => { if data.project.is_none() { let project = provider .get_resource_provider() @@ -327,28 +498,42 @@ impl TokenApi for TokenProvider { data.project = project; } } - _ => {} - }; - Ok(()) - } + Token::FederationProjectScope(ref mut data) => { + if data.project.is_none() { + let project = provider + .get_resource_provider() + .get_project(db, &data.project_id) + .await?; - async fn expand_domain_information( - &self, - token: &mut Token, - db: &DatabaseConnection, - provider: &Provider, - ) -> Result<(), TokenProviderError> { - if let Token::DomainScope(data) = token { - if data.domain.is_none() { - let domain = provider - .get_resource_provider() - .get_domain(db, &data.domain_id) - .await?; - - data.domain = domain; + data.project = project; + } + } + Token::DomainScope(ref mut data) => { + if data.domain.is_none() { + let domain = provider + .get_resource_provider() + .get_domain(db, &data.domain_id) + .await?; + + data.domain = domain; + } + } + Token::FederationDomainScope(ref mut data) => { + if data.domain.is_none() { + let domain = provider + .get_resource_provider() + .get_domain(db, &data.domain_id) + .await?; + + data.domain = domain; + } } + + _ => {} }; - Ok(()) + self.populate_role_assignments(&mut new_token, db, provider) + .await?; + Ok(new_token) } } @@ -360,6 +545,13 @@ mock! { #[async_trait] impl TokenApi for TokenProvider { + async fn authenticate_by_token<'a>( + &self, + credential: &'a str, + allow_expired: Option, + window_seconds: Option, + ) -> Result; + async fn validate_token<'a>( &self, credential: &'a str, @@ -368,16 +560,11 @@ mock! { ) -> Result; #[mockall::concretize] - fn issue_token( + fn issue_token( &self, - user_id: U, - methods: Vec, - audit_ids: Vec, - project: Option<&Project>, - domain: Option<&Domain>, - ) -> Result - where - U: AsRef; + authentication_info: AuthenticatedInfo, + authz_info: AuthzInfo, + ) -> Result; fn encode_token(&self, token: &Token) -> Result; @@ -388,19 +575,12 @@ mock! { provider: &Provider, ) -> Result<(), TokenProviderError>; - async fn expand_project_information( + async fn expand_token_information( &self, - token: &mut Token, + token: &Token, db: &DatabaseConnection, provider: &Provider, - ) -> Result<(), TokenProviderError>; - - async fn expand_domain_information( - &self, - token: &mut Token, - db: &DatabaseConnection, - provider: &Provider, - ) -> Result<(), TokenProviderError>; + ) -> Result; } @@ -476,7 +656,7 @@ mod tests { if let Token::ProjectScope(data) = ptoken { assert_eq!( - data.roles, + data.roles.unwrap(), vec![Role { id: "rid".into(), name: "role_name".into(), @@ -499,7 +679,7 @@ mod tests { if let Token::DomainScope(data) = dtoken { assert_eq!( - data.roles, + data.roles.unwrap(), vec![Role { id: "rid".into(), name: "role_name".into(), diff --git a/src/token/project_scoped.rs b/src/token/project_scoped.rs index 66129732..bdfcaddb 100644 --- a/src/token/project_scoped.rs +++ b/src/token/project_scoped.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use std::io::Write; use crate::assignment::types::Role; +use crate::identity::types::UserResponse; use crate::resource::types::Project; use crate::token::{ error::TokenProviderError, @@ -28,7 +29,7 @@ use crate::token::{ }; #[derive(Builder, Clone, Debug, Default, PartialEq)] -#[builder(setter(strip_option, into))] +#[builder(setter(into))] pub struct ProjectScopePayload { pub user_id: String, #[builder(default, setter(name = _methods))] @@ -39,7 +40,9 @@ pub struct ProjectScopePayload { pub project_id: String, #[builder(default)] - pub roles: Vec, + pub user: Option, + #[builder(default)] + pub roles: Option>, #[builder(default)] pub project: Option, } diff --git a/src/token/types.rs b/src/token/types.rs index 5dbe5ba3..12d970c2 100644 --- a/src/token/types.rs +++ b/src/token/types.rs @@ -15,7 +15,10 @@ use chrono::{DateTime, Utc}; use dyn_clone::DynClone; +use crate::assignment::types::Role; use crate::config::Config; +use crate::identity::types::UserResponse; +use crate::resource::types::{Domain, Project}; use crate::token::TokenProviderError; use crate::token::application_credential::ApplicationCredentialPayload; use crate::token::domain_scoped::DomainScopePayload; @@ -49,6 +52,18 @@ impl Token { } } + pub fn user(&self) -> &Option { + match self { + Token::Unscoped(x) => &x.user, + Token::ProjectScope(x) => &x.user, + Token::DomainScope(x) => &x.user, + Token::FederationUnscoped(x) => &x.user, + Token::FederationProjectScope(x) => &x.user, + Token::FederationDomainScope(x) => &x.user, + Token::ApplicationCredential(x) => &x.user, + } + } + pub fn expires_at(&self) -> &DateTime { match self { Token::Unscoped(x) => &x.expires_at, @@ -84,6 +99,32 @@ impl Token { Token::ApplicationCredential(x) => &x.audit_ids, } } + + pub fn project(&self) -> Option<&Project> { + match self { + Token::ProjectScope(x) => x.project.as_ref(), + Token::FederationProjectScope(x) => x.project.as_ref(), + _ => None, + } + } + + pub fn domain(&self) -> Option<&Domain> { + match self { + Token::DomainScope(x) => x.domain.as_ref(), + Token::FederationDomainScope(x) => x.domain.as_ref(), + _ => None, + } + } + + pub fn roles(&self) -> Option<&Vec> { + match self { + Token::DomainScope(x) => x.roles.as_ref(), + Token::ProjectScope(x) => x.roles.as_ref(), + Token::FederationProjectScope(x) => x.roles.as_ref(), + Token::FederationDomainScope(x) => x.roles.as_ref(), + _ => None, + } + } } pub trait TokenBackend: DynClone + Send + Sync + std::fmt::Debug { diff --git a/src/token/unscoped.rs b/src/token/unscoped.rs index ef75d68d..9004f278 100644 --- a/src/token/unscoped.rs +++ b/src/token/unscoped.rs @@ -18,6 +18,7 @@ use rmp::{decode::read_pfix, encode::write_pfix}; use std::collections::BTreeMap; use std::io::Write; +use crate::identity::types::UserResponse; use crate::token::{ error::TokenProviderError, fernet::{self, MsgPackToken}, @@ -26,7 +27,7 @@ use crate::token::{ }; #[derive(Builder, Clone, Debug, Default, PartialEq)] -#[builder(setter(strip_option, into))] +#[builder(setter(into))] pub struct UnscopedPayload { pub user_id: String, #[builder(default, setter(name = _methods))] @@ -34,6 +35,9 @@ pub struct UnscopedPayload { #[builder(default, setter(name = _audit_ids))] pub audit_ids: Vec, pub expires_at: DateTime, + + #[builder(default)] + pub user: Option, } impl UnscopedPayloadBuilder { @@ -102,6 +106,7 @@ impl MsgPackToken for UnscopedPayload { methods, expires_at, audit_ids, + ..Default::default() }) } } @@ -120,6 +125,7 @@ mod tests { methods: vec!["password".into()], audit_ids: vec!["Zm9vCg".into()], expires_at: Local::now().trunc_subsecs(0).into(), + ..Default::default() }; let auth_map = BTreeMap::from([(1, "password".into())]); let mut buf = vec![];