From b405a1dd7593c393c7b6921f9b7912a92bb44187 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Sun, 23 Feb 2025 21:23:14 +0100 Subject: [PATCH] feat: Sketch token validation endpoint --- src/api/error.rs | 20 ++ src/api/v3/auth/mod.rs | 23 ++ src/api/v3/auth/token/mod.rs | 143 ++++++++++ src/api/v3/auth/token/types.rs | 78 ++++++ src/api/v3/mod.rs | 4 +- src/tests.rs | 1 + src/tests/api.rs | 4 +- src/tests/token.rs | 37 +++ src/token/application_credential.rs | 84 ++++++ src/token/domain_scoped.rs | 79 ++++++ src/token/fernet.rs | 402 +++++----------------------- src/token/fernet_utils.rs | 89 ++++++ src/token/mod.rs | 13 +- src/token/project_scoped.rs | 80 ++++++ src/token/types.rs | 65 ++++- src/token/unscoped.rs | 76 ++++++ 16 files changed, 850 insertions(+), 348 deletions(-) create mode 100644 src/api/v3/auth/mod.rs create mode 100644 src/api/v3/auth/token/mod.rs create mode 100644 src/api/v3/auth/token/types.rs create mode 100644 src/tests/token.rs create mode 100644 src/token/application_credential.rs create mode 100644 src/token/domain_scoped.rs create mode 100644 src/token/project_scoped.rs create mode 100644 src/token/unscoped.rs diff --git a/src/api/error.rs b/src/api/error.rs index cddd073c..bf4b240f 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -37,6 +37,21 @@ pub enum KeystoneApiError { #[error("missing authorization")] Unauthorized(String), + #[error("missing x-subject-token header")] + SubjectTokenMissing, + + #[error("invalid header")] + InvalidHeader, + + #[error("invalid token")] + InvalidToken, + + #[error("error building token data: {}", source)] + TokenBuilder { + #[from] + source: crate::api::v3::auth::token::types::TokenBuilderError, + }, + #[error("internal server error")] InternalError(String), @@ -74,6 +89,11 @@ impl IntoResponse for KeystoneApiError { Json(json!({"error": {"code": StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "message": self.to_string()}})), ).into_response() } + KeystoneApiError::SubjectTokenMissing | KeystoneApiError::InvalidHeader | KeystoneApiError::InvalidToken | KeystoneApiError::TokenBuilder{..} => { + (StatusCode::BAD_REQUEST, + Json(json!({"error": {"code": StatusCode::BAD_REQUEST.as_u16(), "message": self.to_string()}})), + ).into_response() + } } } } diff --git a/src/api/v3/auth/mod.rs b/src/api/v3/auth/mod.rs new file mode 100644 index 00000000..a802a737 --- /dev/null +++ b/src/api/v3/auth/mod.rs @@ -0,0 +1,23 @@ +// 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 utoipa_axum::router::OpenApiRouter; + +use crate::keystone::ServiceState; + +pub mod token; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().nest("/tokens", token::openapi_router()) +} diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs new file mode 100644 index 00000000..af2fe237 --- /dev/null +++ b/src/api/v3/auth/token/mod.rs @@ -0,0 +1,143 @@ +// 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 axum::{extract::State, http::HeaderMap, response::IntoResponse}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use crate::token::{TokenApi, types::TokenData}; +use types::{TokenBuilder, TokenResponse}; + +pub mod types; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(validate)) +} + +/// Validate token +#[utoipa::path( + get, + path = "/", + description = "Validate token", + params(), + responses( + (status = OK, description = "Token object", body = TokenResponse), + ), + tag="auth" +)] +#[tracing::instrument(name = "api::token_get", level = "debug", skip(state))] +async fn validate( + Auth(user_auth): Auth, + headers: HeaderMap, + State(state): State, +) -> Result { + let subject_token: String = headers + .get("X-Subject-Token") + .ok_or(KeystoneApiError::SubjectTokenMissing)? + .to_str() + .map_err(|_| KeystoneApiError::InvalidHeader)? + .to_string(); + + let token = state + .provider + .get_token_provider() + .validate_token(subject_token, None) + .await + .map_err(|_| KeystoneApiError::InvalidToken)?; + + let mut response = TokenBuilder::default(); + response.audit_ids(token.audit_ids().clone()); + response.methods(token.methods().clone()); + response.expires_at(*token.expires_at()); + Ok(TokenResponse { + token: response.build()?, + }) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::openapi_router; + use crate::api::v3::auth::token::types::TokenResponse; + use crate::identity::MockIdentityProvider; + use crate::tests::api::{get_mocked_state, get_mocked_state_unauthed}; + + #[tokio::test] + async fn test_get() { + let identity_mock = MockIdentityProvider::default(); + let state = get_mocked_state(identity_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .header("x-subject-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: TokenResponse = serde_json::from_slice(&body).unwrap(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_get_unauth() { + let state = get_mocked_state_unauthed(); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/src/api/v3/auth/token/types.rs b/src/api/v3/auth/token/types.rs new file mode 100644 index 00000000..de5ecd70 --- /dev/null +++ b/src/api/v3/auth/token/types.rs @@ -0,0 +1,78 @@ +// 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 axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::{DateTime, Utc}; +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Authorization token +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct Token { + /// A list of one or two audit IDs. An audit ID is a unique, randomly generated, URL-safe + /// string that you can use to track a token. The first audit ID is the current audit ID for + /// the token. The second audit ID is present for only re-scoped tokens and is the audit ID + /// from the token before it was re-scoped. A re- scoped token is one that was exchanged for + /// another token of the same or different scope. You can use these audit IDs to track the use + /// of a token or chain of tokens across multiple requests and endpoints without exposing the + /// token ID to non-privileged users. + pub audit_ids: Vec, + + /// The authentication methods, which are commonly password, token, or other methods. Indicates + /// the accumulated set of authentication methods that were used to obtain the token. For + /// example, if the token was obtained by password authentication, it contains password. Later, + /// if the token is exchanged by using the token authentication method one or more times, the + /// subsequently created tokens contain both password and token in their methods attribute. + /// Unlike multi-factor authentication, the methods attribute merely indicates the methods that + /// were used to authenticate the user in exchange for a token. The client is responsible for + /// determining the total number of authentication factors. + pub methods: Vec, + + /// The date and time when the token expires. + pub expires_at: DateTime, + + /// A project object including the id, name and domain object representing the project the + /// token is scoped to. This is only included in tokens that are scoped to a project. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub project: Option, +} + +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct TokenResponse { + /// Token + pub token: Token, +} + +impl IntoResponse for TokenResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +/// Project information +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Project { + /// Project ID + id: String, + /// Project Name + name: String, +} diff --git a/src/api/v3/mod.rs b/src/api/v3/mod.rs index 5c4afc38..a7bc75e7 100644 --- a/src/api/v3/mod.rs +++ b/src/api/v3/mod.rs @@ -16,11 +16,13 @@ use utoipa_axum::router::OpenApiRouter; use crate::keystone::ServiceState; +pub mod auth; pub mod group; pub mod user; pub(super) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() - .nest("/users", user::openapi_router()) + .nest("/auth", auth::openapi_router()) .nest("/groups", group::openapi_router()) + .nest("/users", user::openapi_router()) } diff --git a/src/tests.rs b/src/tests.rs index bafcca80..42a95382 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -13,3 +13,4 @@ // SPDX-License-Identifier: Apache-2.0 pub(crate) mod api; +pub(crate) mod token; diff --git a/src/tests/api.rs b/src/tests/api.rs index b2db2c3a..0aaed7a7 100644 --- a/src/tests/api.rs +++ b/src/tests/api.rs @@ -19,7 +19,7 @@ use crate::config::Config; use crate::identity::MockIdentityProvider; use crate::keystone::{Service, ServiceState}; use crate::provider::ProviderBuilder; -use crate::token::{MockTokenProvider, Token, TokenProviderError}; +use crate::token::{MockTokenProvider, Token, TokenProviderError, UnscopedToken}; pub(crate) fn get_mocked_state_unauthed() -> ServiceState { let db = DatabaseConnection::Disconnected; @@ -46,7 +46,7 @@ pub(crate) fn get_mocked_state(identity_mock: MockIdentityProvider) -> ServiceSt let mut token_mock = MockTokenProvider::default(); token_mock .expect_validate_token() - .returning(|_, _| Ok(Token::default())); + .returning(|_, _| Ok(Token::Unscoped(UnscopedToken::default()))); let provider = ProviderBuilder::default() .config(config.clone()) diff --git a/src/tests/token.rs b/src/tests/token.rs new file mode 100644 index 00000000..72be6ded --- /dev/null +++ b/src/tests/token.rs @@ -0,0 +1,37 @@ +// 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 std::fs::File; +use std::io::Write; +use std::path::PathBuf; +use tempfile::tempdir; + +use crate::config::Config; + +pub fn setup_config() -> Config { + let keys_dir = tempdir().unwrap(); + // write fernet key used to generate tokens in python + let file_path = keys_dir.path().join("0"); + let mut tmp_file = File::create(file_path).unwrap(); + write!(tmp_file, "BFTs1CIVIBLTP4GOrQ26VETrJ7Zwz1O4wbEcCQ966eM=").unwrap(); + let mut config = Config::new(PathBuf::new()).unwrap(); + config.fernet_tokens.key_repository = keys_dir.into_path(); + config.auth.methods = vec![ + "password".into(), + "token".into(), + "openid".into(), + "application_credential".into(), + ]; + config +} diff --git a/src/token/application_credential.rs b/src/token/application_credential.rs new file mode 100644 index 00000000..bcbb8075 --- /dev/null +++ b/src/token/application_credential.rs @@ -0,0 +1,84 @@ +// 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 chrono::{DateTime, Utc}; +use std::collections::BTreeMap; + +use rmp::decode::*; + +use crate::token::{ + error::TokenProviderError, + fernet::{self, MsgPackToken}, + fernet_utils, + types::{Token, TokenData}, +}; + +#[derive(Clone, Debug, Default)] +pub struct ApplicationCredentialToken { + pub user_id: String, + pub methods: Vec, + pub audit_ids: Vec, + pub expires_at: DateTime, + pub project_id: String, + pub application_credential_id: String, +} + +impl TokenData for ApplicationCredentialToken { + fn user_id(&self) -> &String { + &self.user_id + } + fn expires_at(&self) -> &DateTime { + &self.expires_at + } + fn methods(&self) -> &Vec { + &self.methods + } + fn audit_ids(&self) -> &Vec { + &self.audit_ids + } +} + +impl From for Token { + fn from(value: ApplicationCredentialToken) -> Self { + Token::ApplicationCredential(value) + } +} + +impl MsgPackToken for ApplicationCredentialToken { + type Token = ApplicationCredentialToken; + + fn disassemble( + rd: &mut &[u8], + auth_map: &BTreeMap, + ) -> Result { + // Order of reading is important + let user_id = fernet_utils::read_uuid(rd)?; + let methods: Vec = fernet::decode_auth_methods(read_pfix(rd)?.into(), auth_map)? + .into_iter() + .collect(); + let project_id = fernet_utils::read_uuid(rd)?; + let expires_at = fernet_utils::read_time(rd)?; + let audit_ids: Vec = fernet_utils::read_audit_ids(rd)?.into_iter().collect(); + let application_credential_id = fernet_utils::read_uuid(rd)?; + + Ok(Self { + user_id, + methods, + expires_at, + audit_ids, + project_id, + application_credential_id, + }) + } +} diff --git a/src/token/domain_scoped.rs b/src/token/domain_scoped.rs new file mode 100644 index 00000000..a675fd12 --- /dev/null +++ b/src/token/domain_scoped.rs @@ -0,0 +1,79 @@ +// 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 chrono::{DateTime, Utc}; +use std::collections::BTreeMap; + +use rmp::decode::*; + +use crate::token::{ + error::TokenProviderError, + fernet::{self, MsgPackToken}, + fernet_utils, + types::{Token, TokenData}, +}; + +#[derive(Clone, Debug, Default)] +pub struct DomainScopeToken { + pub user_id: String, + pub methods: Vec, + pub audit_ids: Vec, + pub expires_at: DateTime, + pub domain_id: String, +} + +impl TokenData for DomainScopeToken { + fn user_id(&self) -> &String { + &self.user_id + } + fn expires_at(&self) -> &DateTime { + &self.expires_at + } + fn methods(&self) -> &Vec { + &self.methods + } + fn audit_ids(&self) -> &Vec { + &self.audit_ids + } +} + +impl From for Token { + fn from(value: DomainScopeToken) -> Self { + Token::DomainScope(value) + } +} + +impl MsgPackToken for DomainScopeToken { + type Token = DomainScopeToken; + fn disassemble( + rd: &mut &[u8], + auth_map: &BTreeMap, + ) -> Result { + // Order of reading is important + let user_id = fernet_utils::read_uuid(rd)?; + let methods: Vec = fernet::decode_auth_methods(read_pfix(rd)?.into(), auth_map)? + .into_iter() + .collect(); + let domain_id = fernet_utils::read_uuid(rd)?; + let expires_at = fernet_utils::read_time(rd)?; + let audit_ids: Vec = fernet_utils::read_audit_ids(rd)?.into_iter().collect(); + Ok(Self { + user_id, + methods, + expires_at, + audit_ids, + domain_id, + }) + } +} diff --git a/src/token/fernet.rs b/src/token/fernet.rs index 2b8dd2ba..f76c105d 100644 --- a/src/token/fernet.rs +++ b/src/token/fernet.rs @@ -12,21 +12,16 @@ // // SPDX-License-Identifier: Apache-2.0 -use base64::{Engine as _, engine::general_purpose::URL_SAFE}; -use chrono::{DateTime, Utc}; use fernet::{Fernet, MultiFernet}; use rmp::{Marker, decode::*}; use std::collections::BTreeMap; use std::fmt; -use std::io; -use std::io::Read; -use uuid::Uuid; use crate::config::Config; use crate::token::{ - TokenProviderError, - fernet_utils::FernetUtils, - types::{Token, TokenBackend}, + TokenProviderError, application_credential::ApplicationCredentialToken, + domain_scoped::DomainScopeToken, fernet_utils::FernetUtils, project_scoped::ProjectScopeToken, + types::*, unscoped::UnscopedToken, }; #[derive(Default, Clone)] @@ -37,6 +32,14 @@ pub struct FernetTokenProvider { auth_map: BTreeMap, } +pub trait MsgPackToken { + type Token; + fn disassemble( + rd: &mut &[u8], + auth_map: &BTreeMap, + ) -> Result; +} + impl fmt::Debug for FernetTokenProvider { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.debug_struct("FernetTokenProvider").finish() @@ -52,70 +55,8 @@ fn read_payload_token_type(rd: &mut &[u8]) -> Result { } } -/// Read binary data from the payload -fn read_bin_data(len: u32, rd: &mut R) -> Result, io::Error> { - let mut buf = Vec::with_capacity(len.min(1 << 16) as usize); - let bytes_read = rd.take(u64::from(len)).read_to_end(&mut buf)?; - if bytes_read != len as usize { - return Err(io::ErrorKind::UnexpectedEof.into()); - } - Ok(buf) -} - -/// Read string data -fn read_str_data(len: u32, rd: &mut R) -> Result { - Ok(String::from_utf8_lossy(&read_bin_data(len, rd)?).into_owned()) -} - -/// Read the UUID from the payload -/// It is represented as an Array[bool, bytes] where first bool indicates whether following bytes -/// are UUID or just bytes that should be treated as a string (for cases where ID is not a valid -/// UUID) -fn read_uuid(rd: &mut &[u8]) -> Result { - match read_marker(rd).map_err(ValueReadError::from)? { - Marker::FixArray(_) => { - match read_marker(rd).map_err(ValueReadError::from)? { - Marker::True => { - // This is uuid as bytes - // Technically we may fail reading it into bytes, but python part is - // responsible that it doesn not happen - if let Marker::Bin8 = read_marker(rd).map_err(ValueReadError::from)? { - return Ok(Uuid::try_from(read_bin_data(read_pfix(rd)?.into(), rd)?)? - .as_simple() - .to_string()); - } - } - Marker::False => { - // This is not uuid - if let Marker::Bin8 = read_marker(rd).map_err(ValueReadError::from)? { - return Ok(String::from_utf8_lossy(&read_bin_data( - read_pfix(rd)?.into(), - rd, - )?) - .to_string()); - } - } - _ => { - return Err(TokenProviderError::InvalidTokenUuid); - } - } - } - Marker::FixStr(len) => return Ok(read_str_data(len.into(), rd)?), - other => { - return Err(TokenProviderError::InvalidTokenUuidMarker(other)); - } - } - Err(TokenProviderError::InvalidTokenUuid) -} - -/// Read the time represented as a f64 of the UTC seconds -fn read_time(rd: &mut &[u8]) -> Result, TokenProviderError> { - DateTime::from_timestamp(read_f64(rd)?.round() as i64, 0) - .ok_or(TokenProviderError::InvalidToken) -} - /// Decode the integer into the list of auth_methods -fn decode_auth_methods( +pub(crate) fn decode_auth_methods( value: usize, auth_map: &BTreeMap, ) -> Result + use<>, TokenProviderError> { @@ -140,35 +81,15 @@ fn decode_auth_methods( Ok(results.into_iter()) } -/// Decode array of audit ids from the payload -fn read_audit_ids( - rd: &mut &[u8], -) -> Result + use<>, TokenProviderError> { - if let Marker::FixArray(len) = read_marker(rd).map_err(ValueReadError::from)? { - let mut result: Vec = Vec::new(); - for _ in 0..len { - if let Marker::Bin8 = read_marker(rd).map_err(ValueReadError::from)? { - let dt = read_bin_data(read_pfix(rd)?.into(), rd)?; - let audit_id: String = URL_SAFE.encode(dt).trim_end_matches('=').to_string(); - result.push(audit_id); - } else { - return Err(TokenProviderError::InvalidToken); - } - } - return Ok(result.into_iter()); - } - Err(TokenProviderError::InvalidToken) -} - impl FernetTokenProvider { /// Parse binary blob as MessagePack after encrypting it with Fernet fn parse(&self, rd: &mut &[u8]) -> Result { if let Marker::FixArray(_) = read_marker(rd).map_err(ValueReadError::from)? { match read_payload_token_type(rd)? { - 0 => Ok(UnscopedPayload::disassemble(rd, &self.auth_map)?.into()), - 1 => Ok(DomainPayload::disassemble(rd, &self.auth_map)?.into()), - 2 => Ok(ProjectPayload::disassemble(rd, &self.auth_map)?.into()), - 9 => Ok(ApplicationCredentialPayload::disassemble(rd, &self.auth_map)?.into()), + 0 => Ok(UnscopedToken::disassemble(rd, &self.auth_map)?.into()), + 1 => Ok(DomainScopeToken::disassemble(rd, &self.auth_map)?.into()), + 2 => Ok(ProjectScopeToken::disassemble(rd, &self.auth_map)?.into()), + 9 => Ok(ApplicationCredentialToken::disassemble(rd, &self.auth_map)?.into()), other => Err(TokenProviderError::InvalidTokenType(other)), } } else { @@ -208,190 +129,6 @@ impl FernetTokenProvider { } } -/// Unscoped MsgPack payload -#[derive(Debug, Default)] -pub struct UnscopedPayload { - pub user_id: String, - pub methods: Vec, - pub audit_ids: Vec, - pub expires_at: DateTime, -} - -impl From for Token { - fn from(value: UnscopedPayload) -> Self { - Self { - user_id: value.user_id.clone(), - methods: value.methods.clone(), - expires_at: value.expires_at, - audit_ids: value.audit_ids.clone(), - ..Default::default() - } - } -} - -impl UnscopedPayload { - pub fn disassemble( - rd: &mut &[u8], - auth_map: &BTreeMap, - ) -> Result { - // Order of reading is important - let user_id = read_uuid(rd)?; - let methods: Vec = decode_auth_methods(read_pfix(rd)?.into(), auth_map)? - .into_iter() - .collect(); - let expires_at = read_time(rd)?; - let audit_ids: Vec = read_audit_ids(rd)?.into_iter().collect(); - Ok(Self { - user_id, - methods, - expires_at, - audit_ids, - }) - } -} - -/// Domain scoped payload -#[derive(Debug, Default)] -pub struct DomainPayload { - pub user_id: String, - pub methods: Vec, - pub audit_ids: Vec, - pub expires_at: DateTime, - pub domain_id: String, -} - -impl From for Token { - fn from(value: DomainPayload) -> Self { - Self { - user_id: value.user_id.clone(), - methods: value.methods.clone(), - expires_at: value.expires_at, - audit_ids: value.audit_ids.clone(), - domain_id: Some(value.domain_id.clone()), - ..Default::default() - } - } -} - -impl DomainPayload { - pub fn disassemble( - rd: &mut &[u8], - auth_map: &BTreeMap, - ) -> Result { - // Order of reading is important - let user_id = read_uuid(rd)?; - let methods: Vec = decode_auth_methods(read_pfix(rd)?.into(), auth_map)? - .into_iter() - .collect(); - let domain_id = read_uuid(rd)?; - let expires_at = read_time(rd)?; - let audit_ids: Vec = read_audit_ids(rd)?.into_iter().collect(); - Ok(Self { - user_id, - methods, - domain_id, - expires_at, - audit_ids, - }) - } -} - -/// Project scoped payload -#[derive(Debug, Default)] -pub struct ProjectPayload { - pub user_id: String, - pub methods: Vec, - pub audit_ids: Vec, - pub expires_at: DateTime, - pub project_id: String, -} - -impl From for Token { - fn from(value: ProjectPayload) -> Self { - Self { - user_id: value.user_id.clone(), - methods: value.methods.clone(), - expires_at: value.expires_at, - audit_ids: value.audit_ids.clone(), - project_id: Some(value.project_id.clone()), - ..Default::default() - } - } -} - -impl ProjectPayload { - pub fn disassemble( - rd: &mut &[u8], - auth_map: &BTreeMap, - ) -> Result { - // Order of reading is important - let user_id = read_uuid(rd)?; - let methods: Vec = decode_auth_methods(read_pfix(rd)?.into(), auth_map)? - .into_iter() - .collect(); - let project_id = read_uuid(rd)?; - let expires_at = read_time(rd)?; - let audit_ids: Vec = read_audit_ids(rd)?.into_iter().collect(); - Ok(Self { - user_id, - methods, - project_id, - expires_at, - audit_ids, - }) - } -} - -/// Application credential payload -#[derive(Debug, Default)] -pub struct ApplicationCredentialPayload { - pub user_id: String, - pub methods: Vec, - pub audit_ids: Vec, - pub expires_at: DateTime, - pub project_id: String, - pub application_credential_id: String, -} - -impl From for Token { - fn from(value: ApplicationCredentialPayload) -> Self { - Self { - user_id: value.user_id.clone(), - methods: value.methods.clone(), - expires_at: value.expires_at, - audit_ids: value.audit_ids.clone(), - project_id: Some(value.project_id.clone()), - application_credential_id: Some(value.application_credential_id.clone()), - ..Default::default() - } - } -} - -impl ApplicationCredentialPayload { - pub fn disassemble( - rd: &mut &[u8], - auth_map: &BTreeMap, - ) -> Result { - // Order of reading is important - let user_id = read_uuid(rd)?; - let methods: Vec = decode_auth_methods(read_pfix(rd)?.into(), auth_map)? - .into_iter() - .collect(); - let project_id = read_uuid(rd)?; - let expires_at = read_time(rd)?; - let audit_ids: Vec = read_audit_ids(rd)?.into_iter().collect(); - let application_credential_id = read_uuid(rd)?; - Ok(Self { - user_id, - methods, - project_id, - application_credential_id, - expires_at, - audit_ids, - }) - } -} - impl TokenBackend for FernetTokenProvider { /// Set config fn set_config(&mut self, config: Config) { @@ -417,7 +154,7 @@ impl TokenBackend for FernetTokenProvider { } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; use std::fs::File; use std::io::Write; @@ -446,22 +183,24 @@ mod tests { let token = "gAAAAABnt12vpnYCuUxl1lWQfTxwkBcZcgdK5wYons4BFHxxZLk326To5afinp29in7f5ZHR5K61Pl2voIjfbPKlL51KempshD4shfSje4RutbeXq-NT498eEcorzige5XBYGaoWuDTOKEDH2eXCMHhw9722j9iPP3Z4r_1Zlmcqq1n2tndmvsA"; let mut backend = FernetTokenProvider::default(); - let config = setup_config(); + let config = crate::tests::token::setup_config(); backend.set_config(config); backend.load_keys().unwrap(); - let decrypted = backend.decrypt(token.into()).unwrap(); - assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); - assert!(decrypted.project_id.is_none()); - assert_eq!(decrypted.methods, vec!["token"]); - assert_eq!( - decrypted.expires_at.to_rfc3339(), - "2025-02-20T17:40:13+00:00" - ); - assert_eq!( - decrypted.audit_ids, - vec!["sfROvzgjTdmbo8xZdcze-g", "FL7FbzBKQsK115_4TyyiIw"] - ); + if let Token::Unscoped(decrypted) = backend.decrypt(token.into()).unwrap() { + assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); + assert_eq!(decrypted.methods, vec!["token"]); + assert_eq!( + decrypted.expires_at.to_rfc3339(), + "2025-02-20T17:40:13+00:00" + ); + assert_eq!( + decrypted.audit_ids, + vec!["sfROvzgjTdmbo8xZdcze-g", "FL7FbzBKQsK115_4TyyiIw"] + ); + } else { + panic!() + } } #[tokio::test] @@ -473,15 +212,18 @@ mod tests { backend.set_config(config); backend.load_keys().unwrap(); - let decrypted = backend.decrypt(token.into()).unwrap(); - assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); - assert_eq!(decrypted.domain_id, Some("default".into())); - assert_eq!(decrypted.methods, vec!["password"]); - assert_eq!( - decrypted.expires_at.to_rfc3339(), - "2025-02-20T17:55:30+00:00" - ); - assert_eq!(decrypted.audit_ids, vec!["eikbCiM0SsO5P9d_GbVhBQ"]); + if let Token::DomainScope(decrypted) = backend.decrypt(token.into()).unwrap() { + assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); + assert_eq!(decrypted.domain_id, "default"); + assert_eq!(decrypted.methods, vec!["password"]); + assert_eq!( + decrypted.expires_at.to_rfc3339(), + "2025-02-20T17:55:30+00:00" + ); + assert_eq!(decrypted.audit_ids, vec!["eikbCiM0SsO5P9d_GbVhBQ"]); + } else { + panic!() + } } #[tokio::test] @@ -493,18 +235,18 @@ mod tests { backend.set_config(config); backend.load_keys().unwrap(); - let decrypted = backend.decrypt(token.into()).unwrap(); - assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); - assert_eq!( - decrypted.project_id, - Some("97cd761d581b485792a4afc8cc6a998d".into()) - ); - assert_eq!(decrypted.methods, vec!["password"]); - assert_eq!( - decrypted.expires_at.to_rfc3339(), - "2025-02-17T17:49:53+00:00" - ); - assert_eq!(decrypted.audit_ids, vec!["fhRNUHHPTkitISpEYkY_mQ"]); + if let Token::ProjectScope(decrypted) = backend.decrypt(token.into()).unwrap() { + assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); + assert_eq!(decrypted.project_id, "97cd761d581b485792a4afc8cc6a998d"); + assert_eq!(decrypted.methods, vec!["password"]); + assert_eq!( + decrypted.expires_at.to_rfc3339(), + "2025-02-17T17:49:53+00:00" + ); + assert_eq!(decrypted.audit_ids, vec!["fhRNUHHPTkitISpEYkY_mQ"]); + } else { + panic!() + } } #[tokio::test] @@ -516,21 +258,21 @@ mod tests { backend.set_config(config); backend.load_keys().unwrap(); - let decrypted = backend.decrypt(token.into()).unwrap(); - assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); - assert_eq!( - decrypted.project_id, - Some("97cd761d581b485792a4afc8cc6a998d".into()) - ); - assert_eq!(decrypted.methods, vec!["application_credential"]); - assert_eq!( - decrypted.expires_at.to_rfc3339(), - "2025-02-20T17:50:46+00:00" - ); - assert_eq!(decrypted.audit_ids, vec!["kD7Cwc8fSZuWNPZhy0fLVg"]); - assert_eq!( - decrypted.application_credential_id, - Some("a67630c36e1b48839091c905177c5598".into()) - ); + if let Token::ApplicationCredential(decrypted) = backend.decrypt(token.into()).unwrap() { + assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); + assert_eq!(decrypted.project_id, "97cd761d581b485792a4afc8cc6a998d"); + assert_eq!(decrypted.methods, vec!["application_credential"]); + assert_eq!( + decrypted.expires_at.to_rfc3339(), + "2025-02-20T17:50:46+00:00" + ); + assert_eq!(decrypted.audit_ids, vec!["kD7Cwc8fSZuWNPZhy0fLVg"]); + assert_eq!( + decrypted.application_credential_id, + "a67630c36e1b48839091c905177c5598" + ); + } else { + panic!() + } } } diff --git a/src/token/fernet_utils.rs b/src/token/fernet_utils.rs index 19e249f6..de5b9c32 100644 --- a/src/token/fernet_utils.rs +++ b/src/token/fernet_utils.rs @@ -11,11 +11,18 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; +use chrono::{DateTime, Utc}; +use rmp::{Marker, decode::*}; use std::collections::BTreeMap; use std::fs; +use std::io; +use std::io::Read; use std::path::PathBuf; use tokio::fs as fs_async; use tracing::trace; +use uuid::Uuid; use crate::token::error::TokenProviderError; @@ -70,6 +77,88 @@ impl FernetUtils { } } +/// Read binary data from the payload +pub fn read_bin_data(len: u32, rd: &mut R) -> Result, io::Error> { + let mut buf = Vec::with_capacity(len.min(1 << 16) as usize); + let bytes_read = rd.take(u64::from(len)).read_to_end(&mut buf)?; + if bytes_read != len as usize { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + Ok(buf) +} + +/// Read string data +pub fn read_str_data(len: u32, rd: &mut R) -> Result { + Ok(String::from_utf8_lossy(&read_bin_data(len, rd)?).into_owned()) +} + +/// Read the UUID from the payload +/// It is represented as an Array[bool, bytes] where first bool indicates whether following bytes +/// are UUID or just bytes that should be treated as a string (for cases where ID is not a valid +/// UUID) +pub fn read_uuid(rd: &mut &[u8]) -> Result { + match read_marker(rd).map_err(ValueReadError::from)? { + Marker::FixArray(_) => { + match read_marker(rd).map_err(ValueReadError::from)? { + Marker::True => { + // This is uuid as bytes + // Technically we may fail reading it into bytes, but python part is + // responsible that it doesn not happen + if let Marker::Bin8 = read_marker(rd).map_err(ValueReadError::from)? { + return Ok(Uuid::try_from(read_bin_data(read_pfix(rd)?.into(), rd)?)? + .as_simple() + .to_string()); + } + } + Marker::False => { + // This is not uuid + if let Marker::Bin8 = read_marker(rd).map_err(ValueReadError::from)? { + return Ok(String::from_utf8_lossy(&read_bin_data( + read_pfix(rd)?.into(), + rd, + )?) + .to_string()); + } + } + _ => { + return Err(TokenProviderError::InvalidTokenUuid); + } + } + } + Marker::FixStr(len) => return Ok(read_str_data(len.into(), rd)?), + other => { + return Err(TokenProviderError::InvalidTokenUuidMarker(other)); + } + } + Err(TokenProviderError::InvalidTokenUuid) +} + +/// Read the time represented as a f64 of the UTC seconds +pub fn read_time(rd: &mut &[u8]) -> Result, TokenProviderError> { + DateTime::from_timestamp(read_f64(rd)?.round() as i64, 0) + .ok_or(TokenProviderError::InvalidToken) +} + +/// Decode array of audit ids from the payload +pub fn read_audit_ids( + rd: &mut &[u8], +) -> Result + use<>, TokenProviderError> { + if let Marker::FixArray(len) = read_marker(rd).map_err(ValueReadError::from)? { + let mut result: Vec = Vec::new(); + for _ in 0..len { + if let Marker::Bin8 = read_marker(rd).map_err(ValueReadError::from)? { + let dt = read_bin_data(read_pfix(rd)?.into(), rd)?; + let audit_id: String = URL_SAFE.encode(dt).trim_end_matches('=').to_string(); + result.push(audit_id); + } else { + return Err(TokenProviderError::InvalidToken); + } + } + return Ok(result.into_iter()); + } + Err(TokenProviderError::InvalidToken) +} + #[cfg(test)] mod tests { use super::FernetUtils; diff --git a/src/token/mod.rs b/src/token/mod.rs index db2602fb..5c15a004 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -17,16 +17,25 @@ use chrono::{Local, TimeDelta}; #[cfg(test)] use mockall::mock; +pub mod application_credential; +pub mod domain_scoped; mod error; pub mod fernet; pub mod fernet_utils; +pub mod project_scoped; pub mod types; +pub mod unscoped; use crate::config::{Config, TokenProvider as TokenProviderType}; pub use error::TokenProviderError; use types::TokenBackend; +pub use application_credential::ApplicationCredentialToken; +pub use domain_scoped::DomainScopeToken; +pub use project_scoped::ProjectScopeToken; pub use types::Token; +use types::TokenData; +pub use unscoped::UnscopedToken; #[derive(Clone, Debug)] pub struct TokenProvider { @@ -66,9 +75,9 @@ impl TokenApi for TokenProvider { let token = self.backend_driver.extract(credential)?; if Local::now().to_utc() > token - .expires_at + .expires_at() .checked_add_signed(TimeDelta::seconds(window_seconds.unwrap_or(0))) - .unwrap_or(token.expires_at) + .unwrap_or(*token.expires_at()) { return Err(TokenProviderError::Expired); } diff --git a/src/token/project_scoped.rs b/src/token/project_scoped.rs new file mode 100644 index 00000000..a93b982c --- /dev/null +++ b/src/token/project_scoped.rs @@ -0,0 +1,80 @@ +// 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 chrono::{DateTime, Utc}; +use std::collections::BTreeMap; + +use rmp::decode::*; + +use crate::token::{ + error::TokenProviderError, + fernet::{self, MsgPackToken}, + fernet_utils, + types::{Token, TokenData}, +}; + +#[derive(Clone, Debug, Default)] +pub struct ProjectScopeToken { + pub user_id: String, + pub methods: Vec, + pub audit_ids: Vec, + pub expires_at: DateTime, + pub project_id: String, +} + +impl TokenData for ProjectScopeToken { + fn user_id(&self) -> &String { + &self.user_id + } + fn expires_at(&self) -> &DateTime { + &self.expires_at + } + fn methods(&self) -> &Vec { + &self.methods + } + fn audit_ids(&self) -> &Vec { + &self.audit_ids + } +} + +impl From for Token { + fn from(value: ProjectScopeToken) -> Self { + Token::ProjectScope(value) + } +} + +impl MsgPackToken for ProjectScopeToken { + type Token = ProjectScopeToken; + + fn disassemble( + rd: &mut &[u8], + auth_map: &BTreeMap, + ) -> Result { + // Order of reading is important + let user_id = fernet_utils::read_uuid(rd)?; + let methods: Vec = fernet::decode_auth_methods(read_pfix(rd)?.into(), auth_map)? + .into_iter() + .collect(); + let project_id = fernet_utils::read_uuid(rd)?; + let expires_at = fernet_utils::read_time(rd)?; + let audit_ids: Vec = fernet_utils::read_audit_ids(rd)?.into_iter().collect(); + Ok(Self { + user_id, + methods, + expires_at, + audit_ids, + project_id, + }) + } +} diff --git a/src/token/types.rs b/src/token/types.rs index dea9c266..3d22fb48 100644 --- a/src/token/types.rs +++ b/src/token/types.rs @@ -17,20 +17,59 @@ use dyn_clone::DynClone; use crate::config::Config; use crate::token::TokenProviderError; +use crate::token::application_credential::ApplicationCredentialToken; +use crate::token::domain_scoped::DomainScopeToken; +use crate::token::project_scoped::ProjectScopeToken; +use crate::token::unscoped::UnscopedToken; -#[derive(Clone, Debug, Default)] -pub struct Token { - pub user_id: String, - pub methods: Vec, - pub audit_ids: Vec, - pub expires_at: DateTime, - pub project_id: Option, - pub domain_id: Option, - pub trust_id: Option, - pub application_credential_id: Option, - pub access_token_id: Option, - pub system: Option, - pub federated_group_ids: Option>, +#[derive(Clone, Debug)] +pub enum Token { + Unscoped(UnscopedToken), + DomainScope(DomainScopeToken), + ProjectScope(ProjectScopeToken), + ApplicationCredential(ApplicationCredentialToken), +} + +pub trait TokenData { + fn user_id(&self) -> &String; + fn expires_at(&self) -> &DateTime; + fn methods(&self) -> &Vec; + fn audit_ids(&self) -> &Vec; +} + +impl TokenData for Token { + fn user_id(&self) -> &String { + match self { + Token::Unscoped(x) => x.user_id(), + Token::ProjectScope(x) => x.user_id(), + Token::DomainScope(x) => x.user_id(), + Token::ApplicationCredential(x) => x.user_id(), + } + } + fn expires_at(&self) -> &DateTime { + match self { + Token::Unscoped(x) => x.expires_at(), + Token::ProjectScope(x) => x.expires_at(), + Token::DomainScope(x) => x.expires_at(), + Token::ApplicationCredential(x) => x.expires_at(), + } + } + fn methods(&self) -> &Vec { + match self { + Token::Unscoped(x) => x.methods(), + Token::ProjectScope(x) => x.methods(), + Token::DomainScope(x) => x.methods(), + Token::ApplicationCredential(x) => x.methods(), + } + } + fn audit_ids(&self) -> &Vec { + match self { + Token::Unscoped(x) => x.audit_ids(), + Token::ProjectScope(x) => x.audit_ids(), + Token::DomainScope(x) => x.audit_ids(), + Token::ApplicationCredential(x) => x.audit_ids(), + } + } } pub trait TokenBackend: DynClone + Send + Sync + std::fmt::Debug { diff --git a/src/token/unscoped.rs b/src/token/unscoped.rs new file mode 100644 index 00000000..1e4e9175 --- /dev/null +++ b/src/token/unscoped.rs @@ -0,0 +1,76 @@ +// 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 chrono::{DateTime, Utc}; +use std::collections::BTreeMap; + +use rmp::decode::*; + +use crate::token::{ + error::TokenProviderError, + fernet::{self, MsgPackToken}, + fernet_utils, + types::{Token, TokenData}, +}; + +#[derive(Clone, Debug, Default)] +pub struct UnscopedToken { + pub user_id: String, + pub methods: Vec, + pub audit_ids: Vec, + pub expires_at: DateTime, +} + +impl TokenData for UnscopedToken { + fn user_id(&self) -> &String { + &self.user_id + } + fn expires_at(&self) -> &DateTime { + &self.expires_at + } + fn methods(&self) -> &Vec { + &self.methods + } + fn audit_ids(&self) -> &Vec { + &self.audit_ids + } +} + +impl From for Token { + fn from(value: UnscopedToken) -> Self { + Token::Unscoped(value) + } +} + +impl MsgPackToken for UnscopedToken { + type Token = UnscopedToken; + fn disassemble( + rd: &mut &[u8], + auth_map: &BTreeMap, + ) -> Result { + // Order of reading is important + let user_id = fernet_utils::read_uuid(rd)?; + let methods: Vec = fernet::decode_auth_methods(read_pfix(rd)?.into(), auth_map)? + .into_iter() + .collect(); + let expires_at = fernet_utils::read_time(rd)?; + let audit_ids: Vec = fernet_utils::read_audit_ids(rd)?.into_iter().collect(); + Ok(Self::Token { + user_id, + methods, + expires_at, + audit_ids, + }) + } +}