From a597066817173b5a0177fc2394cb6f5133967ce2 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Wed, 26 Mar 2025 22:17:56 +0100 Subject: [PATCH] feat: Add allow_expired QP to token validation api --- src/api/auth.rs | 2 +- src/api/error.rs | 2 +- src/api/v3/auth/token/mod.rs | 160 +++++++++++++++++++++++++++++- src/api/v3/auth/token/types.rs | 9 ++ src/api/v3/role/mod.rs | 2 +- src/api/v3/role_assignment/mod.rs | 2 +- src/config.rs | 5 +- src/tests/api.rs | 4 +- src/token/mod.rs | 4 + 9 files changed, 178 insertions(+), 12 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index 02d1d5a5..aca5de8f 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -49,7 +49,7 @@ where state .provider .get_token_provider() - .validate_token(auth_header, None) + .validate_token(auth_header, Some(false), None) .await .map_err(|_| (StatusCode::UNAUTHORIZED, "not authorized"))?, )) diff --git a/src/api/error.rs b/src/api/error.rs index 6539b86e..3a2783c6 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -45,7 +45,7 @@ pub enum KeystoneApiError { #[error("missing x-subject-token header")] SubjectTokenMissing, - #[error("invalid header")] + #[error("invalid header header")] InvalidHeader, #[error("invalid token")] diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index b9fe7ad3..ef86246e 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -33,7 +33,10 @@ use crate::resource::{ types::{Domain, Project}, }; use crate::token::TokenApi; -use types::{AuthRequest, CreateTokenParameters, Scope, Token as ApiResponseToken, TokenResponse}; +use types::{ + AuthRequest, CreateTokenParameters, Scope, Token as ApiResponseToken, TokenResponse, + ValidateTokenParameters, +}; mod common; pub mod types; @@ -206,7 +209,7 @@ async fn post( get, path = "/", description = "Validate token", - params(), + params(ValidateTokenParameters), responses( (status = OK, description = "Token object", body = TokenResponse), ), @@ -219,6 +222,7 @@ async fn post( )] async fn show( Auth(_user_auth): Auth, + Query(query): Query, headers: HeaderMap, State(state): State, ) -> Result { @@ -232,9 +236,12 @@ async fn show( let mut token = state .provider .get_token_provider() - .validate_token(&subject_token, None) + .validate_token(&subject_token, query.allow_expired, None) .await - .map_err(|_| KeystoneApiError::InvalidToken)?; + .map_err(|_| KeystoneApiError::NotFound { + resource: "token".into(), + identifier: String::new(), + })?; state .provider @@ -315,7 +322,7 @@ mod tests { })) }); let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _| { + token_mock.expect_validate_token().returning(|_, _, _| { Ok(Token::Unscoped(UnscopedToken { user_id: "bar".into(), ..Default::default() @@ -380,6 +387,149 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + #[tokio::test] + async fn test_get_allow_expired() { + let db = DatabaseConnection::Disconnected; + let config = Config::default(); + let assignment_mock = MockAssignmentProvider::default(); + let catalog_mock = MockCatalogProvider::default(); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id: &'_ str| { + Ok(Some(UserResponse { + id: id.to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + })) + }); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_domain() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "user_domain_id") + .returning(|_, _| { + Ok(Some(Domain { + id: "user_domain_id".into(), + ..Default::default() + })) + }); + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, _, _| token == "foo") + .returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedToken { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, allow_expired: &Option, _| { + token == "bar" && *allow_expired == Some(true) + }) + .returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedToken { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_populate_role_assignments() + .returning(|_, _, _| Ok(())); + token_mock + .expect_expand_project_information() + .returning(|_, _, _| Ok(())); + token_mock + .expect_expand_domain_information() + .returning(|_, _, _| Ok(())); + + let provider = ProviderBuilder::default() + .config(config.clone()) + .assignment(assignment_mock) + .catalog(catalog_mock) + .identity(identity_mock) + .resource(resource_mock) + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new(Service::new(config, db, 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("/?allow_expired=true") + .header("x-auth-token", "foo") + .header("x-subject-token", "bar") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_get_expired() { + let db = DatabaseConnection::Disconnected; + let config = Config::default(); + let assignment_mock = MockAssignmentProvider::default(); + let catalog_mock = MockCatalogProvider::default(); + let identity_mock = MockIdentityProvider::default(); + let resource_mock = MockResourceProvider::default(); + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, _, _| token == "foo") + .returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedToken { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_validate_token() + .withf(|token: &'_ str, _, _| token == "bar") + .returning(|_, _, _| Err(TokenProviderError::Expired)); + + let provider = ProviderBuilder::default() + .config(config.clone()) + .assignment(assignment_mock) + .catalog(catalog_mock) + .identity(identity_mock) + .resource(resource_mock) + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new(Service::new(config, db, 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("/") + .header("x-auth-token", "foo") + .header("x-subject-token", "bar") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn test_get_unauth() { let state = get_mocked_state_unauthed(); diff --git a/src/api/v3/auth/token/types.rs b/src/api/v3/auth/token/types.rs index d58fea8f..e09829c6 100644 --- a/src/api/v3/auth/token/types.rs +++ b/src/api/v3/auth/token/types.rs @@ -280,3 +280,12 @@ pub struct CreateTokenParameters { /// the service catalog. pub nocatalog: Option, } + +#[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams)] +pub struct ValidateTokenParameters { + /// The authentication response excludes the service catalog. By default, the response includes + /// the service catalog. + pub nocatalog: Option, + /// Allow fetching a token that has expired. By default expired tokens return a 404 exception. + pub allow_expired: Option, +} diff --git a/src/api/v3/role/mod.rs b/src/api/v3/role/mod.rs index edb4a045..30c4b4c8 100644 --- a/src/api/v3/role/mod.rs +++ b/src/api/v3/role/mod.rs @@ -131,7 +131,7 @@ mod tests { let config = Config::default(); let mut token_mock = MockTokenProvider::default(); let resource_mock = MockResourceProvider::default(); - token_mock.expect_validate_token().returning(|_, _| { + token_mock.expect_validate_token().returning(|_, _, _| { Ok(Token::Unscoped(UnscopedToken { user_id: "bar".into(), ..Default::default() diff --git a/src/api/v3/role_assignment/mod.rs b/src/api/v3/role_assignment/mod.rs index cc3237e6..4fe9d9d5 100644 --- a/src/api/v3/role_assignment/mod.rs +++ b/src/api/v3/role_assignment/mod.rs @@ -101,7 +101,7 @@ mod tests { let config = Config::default(); let mut token_mock = MockTokenProvider::default(); let resource_mock = MockResourceProvider::default(); - token_mock.expect_validate_token().returning(|_, _| { + token_mock.expect_validate_token().returning(|_, _, _| { Ok(Token::Unscoped(UnscopedToken { user_id: "bar".into(), ..Default::default() diff --git a/src/config.rs b/src/config.rs index 4b3149f7..012531af 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,7 +67,10 @@ pub struct Config { } #[derive(Debug, Default, Deserialize, Clone)] -pub struct DefaultSection {} +pub struct DefaultSection { + /// Debug logging + pub debug: Option, +} #[derive(Debug, Default, Deserialize, Clone)] pub struct AuthSection { diff --git a/src/tests/api.rs b/src/tests/api.rs index fc53783f..bf8393c2 100644 --- a/src/tests/api.rs +++ b/src/tests/api.rs @@ -34,7 +34,7 @@ pub(crate) fn get_mocked_state_unauthed() -> ServiceState { let mut token_mock = MockTokenProvider::default(); token_mock .expect_validate_token() - .returning(|_, _| Err(TokenProviderError::InvalidToken)); + .returning(|_, _, _| Err(TokenProviderError::InvalidToken)); let provider = ProviderBuilder::default() .config(config.clone()) @@ -54,7 +54,7 @@ pub(crate) fn get_mocked_state(identity_mock: MockIdentityProvider) -> ServiceSt let config = Config::default(); let mut token_mock = MockTokenProvider::default(); let resource_mock = MockResourceProvider::default(); - token_mock.expect_validate_token().returning(|_, _| { + token_mock.expect_validate_token().returning(|_, _, _| { Ok(Token::Unscoped(UnscopedToken { user_id: "bar".into(), ..Default::default() diff --git a/src/token/mod.rs b/src/token/mod.rs index 727d8f40..e72ac0dc 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -72,6 +72,7 @@ pub trait TokenApi: Send + Sync + Clone { async fn validate_token<'a>( &self, credential: &'a str, + allow_expired: Option, window_seconds: Option, ) -> Result; @@ -122,6 +123,7 @@ impl TokenApi for TokenProvider { async fn validate_token<'a>( &self, credential: &'a str, + allow_expired: Option, window_seconds: Option, ) -> Result { let token = self.backend_driver.decode(credential)?; @@ -130,6 +132,7 @@ impl TokenApi for TokenProvider { .expires_at() .checked_add_signed(TimeDelta::seconds(window_seconds.unwrap_or(0))) .unwrap_or(*token.expires_at()) + && !allow_expired.unwrap_or(false) { return Err(TokenProviderError::Expired); } @@ -351,6 +354,7 @@ mock! { async fn validate_token<'a>( &self, credential: &'a str, + allow_expired: Option, window_seconds: Option, ) -> Result;